46 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
101 changed files with 17273 additions and 4847 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

@@ -34,6 +34,8 @@
dotnetCorePackages.sdk_8_0
])
nuget
mono
vlc
# msbuild
omnisharp-roslyn
csharpier

4034
nohup.out Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@ using server.Services;
public class ProgressTrackerTest
{
[Fact]
public async Task Test_ProgressReporter_Basic()
public void Test_ProgressReporter_Basic()
{
int reportedValue = -1;
var reporter = new ProgressReporter(async v => { reportedValue = v; await Task.CompletedTask; }, 0, 100, 10);

View File

@@ -86,7 +86,10 @@ try
var path = context.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) && (
path.StartsWithSegments("/hubs/JtagHub") ||
path.StartsWithSegments("/hubs/ProgressHub")
path.StartsWithSegments("/hubs/ProgressHub") ||
path.StartsWithSegments("/hubs/DigitalTubesHub") ||
path.StartsWithSegments("/hubs/RotaryEncoderHub") ||
path.StartsWithSegments("/hubs/OscilloscopeHub")
))
{
// Read the token out of the query string
@@ -127,7 +130,7 @@ try
.AllowAnyHeader()
);
options.AddPolicy("SignalR", policy => policy
.WithOrigins("http://localhost:5173")
.WithOrigins([$"http://{Global.LocalHost}:5173", "http://127.0.0.1:5173"])
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials()
@@ -172,12 +175,6 @@ try
options.OperationProcessors.Add(new OperationSecurityScopeProcessor("Bearer"));
});
// 添加数据库资源管理器服务
builder.Services.AddScoped<Database.AppDataConnection>();
builder.Services.AddScoped<Database.UserManager>();
builder.Services.AddScoped<Database.ResourceManager>();
builder.Services.AddScoped<Database.ExamManager>();
// 添加 HTTP 视频流服务
builder.Services.AddSingleton<HttpVideoStreamService>();
builder.Services.AddHostedService(provider => provider.GetRequiredService<HttpVideoStreamService>());
@@ -185,8 +182,7 @@ try
builder.Services.AddHostedService(provider => provider.GetRequiredService<HttpHdmiVideoStreamService>());
// 添加进度跟踪服务
builder.Services.AddSingleton<ProgressTrackerService>();
builder.Services.AddHostedService(provider => provider.GetRequiredService<ProgressTrackerService>());
builder.Services.AddSingleton<ProgressTracker>();
// Application Settings
var app = builder.Build();
@@ -257,11 +253,16 @@ try
// Router
app.MapControllers();
app.MapHub<server.Hubs.JtagHub>("hubs/JtagHub");
app.MapHub<server.Hubs.ProgressHub>("hubs/ProgressHub");
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();
var progressTracker = app.Services.GetRequiredService<ProgressTracker>();
MsgBus.SetProgressTracker(progressTracker);
// Generate API Client
app.MapGet("GetAPIClientCode", async (HttpContext context) =>

View File

@@ -18,6 +18,8 @@
<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" />
@@ -29,9 +31,8 @@
<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>

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

@@ -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

@@ -18,16 +18,11 @@ public class DataController : ControllerBase
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly Database.UserManager _userManager;
private readonly Database.UserManager _userManager = new();
// 固定的实验板IP,端口,MAC地址
private const string BOARD_IP = "169.254.109.0";
public DataController(Database.UserManager userManager)
{
_userManager = userManager;
}
/// <summary>
/// 获取本机IP地址优先选择与实验板同网段的IP
/// </summary>

View File

@@ -15,12 +15,7 @@ public class DebuggerController : ControllerBase
{
private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly Database.UserManager _userManager;
public DebuggerController(Database.UserManager userManager)
{
this._userManager = userManager;
}
private readonly Database.UserManager _userManager = new();
/// <summary>
/// 获取当前用户绑定的调试器实例
@@ -46,7 +41,7 @@ public class DebuggerController : ControllerBase
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)
{

View File

@@ -15,19 +15,9 @@ public class ExamController : ControllerBase
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly ExamManager _examManager;
private readonly ResourceManager _resourceManager;
private readonly UserManager _userManager;
public ExamController(
ExamManager examManager,
ResourceManager resourceManager,
UserManager userManager)
{
_examManager = examManager;
_resourceManager = resourceManager;
_userManager = userManager;
}
private readonly ExamManager _examManager = new();
private readonly ResourceManager _resourceManager = new();
private readonly UserManager _userManager = new();
/// <summary>
/// 获取所有实验列表
@@ -229,12 +219,12 @@ public class ExamController : ControllerBase
[Authorize]
[HttpPost("commit/{examId}")]
[EnableCors("Users")]
[ProducesResponseType(typeof(Resource), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ResourceInfo), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> SubmitHomework(string examId, IFormFile file)
public async Task<IActionResult> Commit(string examId, IFormFile file)
{
if (string.IsNullOrWhiteSpace(examId))
return BadRequest("实验ID不能为空");
@@ -287,7 +277,7 @@ public class ExamController : ControllerBase
return StatusCode(StatusCodes.Status500InternalServerError, $"提交作业失败: {commitResult.Error.Message}");
}
var commit = commitResult.Value;
var commit = new ResourceInfo(commitResult.Value);
logger.Info($"用户 {userName} 成功提交实验 {examId} 的作业Commit ID: {commit.ID}");
return CreatedAtAction(nameof(GetCommitsByExamId), new { examId = examId }, commit);
@@ -307,7 +297,7 @@ public class ExamController : ControllerBase
[Authorize]
[HttpGet("commits/{examId}")]
[EnableCors("Users")]
[ProducesResponseType(typeof(Resource[]), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ResourceInfo[]), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -352,8 +342,7 @@ public class ExamController : ControllerBase
logger.Error($"获取提交记录时出错: {commitsResult.Error.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"获取提交记录失败: {commitsResult.Error.Message}");
}
var commits = commitsResult.Value;
var commits = commitsResult.Value.Select(x => new ResourceInfo(x)).ToArray();
logger.Info($"成功获取用户 {userName} 在实验 {examId} 中的提交记录,共 {commits.Length} 条");
return Ok(commits);
@@ -379,11 +368,8 @@ public class ExamController : ControllerBase
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult DeleteCommit(string commitId)
public IActionResult DeleteCommit(Guid commitId)
{
if (!Guid.TryParse(commitId, out _))
return BadRequest("提交记录ID格式不正确");
try
{
// 获取当前用户信息

View File

@@ -14,12 +14,11 @@ public class HdmiVideoStreamController : ControllerBase
private readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly HttpHdmiVideoStreamService _videoStreamService;
private readonly Database.UserManager _userManager;
private readonly Database.UserManager _userManager = new();
public HdmiVideoStreamController(HttpHdmiVideoStreamService videoStreamService, Database.UserManager userManager)
public HdmiVideoStreamController(HttpHdmiVideoStreamService videoStreamService)
{
_videoStreamService = videoStreamService;
_userManager = userManager;
}
// 管理员获取所有板子的 endpoints

View File

@@ -16,20 +16,12 @@ public class JtagController : ControllerBase
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly ProgressTrackerService _tracker;
private readonly UserManager _userManager;
private readonly ResourceManager _resourceManager;
private readonly ProgressTracker _tracker = MsgBus.ProgressTracker;
private readonly UserManager _userManager = new();
private readonly ResourceManager _resourceManager = new();
private const string BITSTREAM_PATH = "bitstream/Jtag";
public JtagController(
ProgressTrackerService tracker, UserManager userManager, ResourceManager resourceManager)
{
_tracker = tracker;
_userManager = userManager;
_resourceManager = resourceManager;
}
/// <summary>
/// 控制器首页信息
/// </summary>
@@ -140,7 +132,7 @@ public class JtagController : ControllerBase
[ProducesResponseType(typeof(string), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(Exception), StatusCodes.Status500InternalServerError)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public IResult DownloadBitstream(string address, int port, string bitstreamId, CancellationToken cancelToken)
public IResult DownloadBitstream(string address, int port, Guid bitstreamId, CancellationToken cancelToken)
{
logger.Info($"User {User.Identity?.Name} initiating bitstream download to device {address}:{port} using bitstream ID: {bitstreamId}");
@@ -191,8 +183,8 @@ public class JtagController : ControllerBase
logger.Info($"User {username} processing bitstream file of size: {fileBytes.Length} bytes");
// 定义进度跟踪
var (taskId, progress) = _tracker.CreateTask(cancelToken);
progress.Report(10);
var taskId = _tracker.CreateTask(8000);
_tracker.AdvanceProgress(taskId, 10);
_ = Task.Run(async () =>
{
@@ -213,7 +205,8 @@ public class JtagController : ControllerBase
if (!retBuffer.IsSuccessful)
{
logger.Error($"User {username} failed to reverse bytes: {retBuffer.Error}");
progress.Error($"User {username} failed to reverse bytes: {retBuffer.Error}");
_tracker.FailProgress(taskId,
$"User {username} failed to reverse bytes: {retBuffer.Error}");
return;
}
revBuffer = retBuffer.Value;
@@ -231,21 +224,22 @@ public class JtagController : ControllerBase
var processedBytes = outputStream.ToArray();
logger.Info($"User {username} processed {totalBytesProcessed} bytes for device {address}");
progress.Report(20);
_tracker.AdvanceProgress(taskId, 20);
// 下载比特流
var jtagCtrl = new Peripherals.JtagClient.Jtag(address, port);
var ret = await jtagCtrl.DownloadBitstream(processedBytes);
var ret = await jtagCtrl.DownloadBitstream(processedBytes, taskId);
if (ret.IsSuccessful)
{
logger.Info($"User {username} successfully downloaded bitstream '{resource.ResourceName}' to device {address}");
progress.Finish();
_tracker.CompleteProgress(taskId);
}
else
{
logger.Error($"User {username} failed to download bitstream to device {address}: {ret.Error}");
progress.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}");
}
}
});

View File

@@ -15,12 +15,7 @@ public class LogicAnalyzerController : ControllerBase
{
private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly Database.UserManager _userManager;
public LogicAnalyzerController(Database.UserManager userManager)
{
_userManager = userManager;
}
private readonly Database.UserManager _userManager = new();
/// <summary>
/// 获取逻辑分析仪实例
@@ -46,7 +41,7 @@ public class LogicAnalyzerController : ControllerBase
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)
{

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,23 +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();
private readonly Database.UserManager _userManager;
public OscilloscopeApiController(Database.UserManager userManager)
{
_userManager = userManager;
}
private readonly Database.UserManager _userManager = new();
/// <summary>
/// 获取示波器实例
/// </summary>
private Oscilloscope? GetOscilloscope()
private OscilloscopeCtrl? GetOscilloscope()
{
try
{
@@ -46,7 +43,7 @@ public class OscilloscopeApiController : ControllerBase
return null;
var board = boardRet.Value.Value;
return new Oscilloscope(board.IpAddr, board.Port);
return new OscilloscopeCtrl(board.IpAddr, board.Port);
}
catch (Exception ex)
{
@@ -61,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
{
@@ -123,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);
@@ -156,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)]
@@ -190,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)]
@@ -224,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)]
@@ -275,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)
};
@@ -298,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)]
@@ -343,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)]
@@ -392,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)]
@@ -420,72 +410,4 @@ public class OscilloscopeApiController : ControllerBase
return StatusCode(StatusCodes.Status500InternalServerError, "操作失败,请稍后重试");
}
}
/// <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;
}
}

View File

@@ -15,14 +15,8 @@ public class ResourceController : ControllerBase
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly UserManager _userManager;
private readonly ResourceManager _resourceManager;
public ResourceController(UserManager userManager, ResourceManager resourceManager)
{
_userManager = userManager;
_resourceManager = resourceManager;
}
private readonly UserManager _userManager = new();
private readonly ResourceManager _resourceManager = new();
/// <summary>
/// 添加资源(文件上传)
@@ -43,10 +37,6 @@ public class ResourceController : ControllerBase
if (string.IsNullOrWhiteSpace(request.ResourceType) || file == null)
return BadRequest("资源类型、资源用途和文件不能为空");
// 验证资源用途
if (request.ResourcePurpose != ResourcePurpose.Template && request.ResourcePurpose != ResourcePurpose.User)
return BadRequest($"无效的资源用途: {request.ResourcePurpose}");
// 模板资源需要管理员权限
if (request.ResourcePurpose == ResourcePurpose.Template && !User.IsInRole("Admin"))
return Forbid("只有管理员可以添加模板资源");
@@ -82,20 +72,10 @@ public class ResourceController : ControllerBase
return StatusCode(StatusCodes.Status500InternalServerError, $"添加资源失败: {result.Error.Message}");
}
var resource = result.Value;
var resourceInfo = new ResourceInfo
{
ID = resource.ID.ToString(),
Name = resource.ResourceName,
Type = resource.ResourceType,
Purpose = resource.Purpose,
UploadTime = resource.UploadTime,
ExamID = resource.ExamID,
MimeType = resource.MimeType
};
var resourceInfo = new ResourceInfo(result.Value);
logger.Info($"成功添加资源: {request.ResourceType}/{request.ResourcePurpose}/{file.FileName}");
return CreatedAtAction(nameof(GetResourceById), new { resourceId = resource.ID }, resourceInfo);
logger.Info($"成功添加资源: {request.ResourceType}/{request.ResourcePurpose}/{file.FileName} ID: {resourceInfo.ID}");
return CreatedAtAction(nameof(GetResourceById), new { resourceId = resourceInfo.ID }, resourceInfo);
}
catch (Exception ex)
{
@@ -168,16 +148,7 @@ public class ResourceController : ControllerBase
var allResources = userResourcesResult.Value.Concat(templateResourcesResult.Value)
.OrderByDescending(r => r.UploadTime);
var mergedResourceInfos = allResources.Select(r => new ResourceInfo
{
ID = r.ID.ToString(),
Name = r.ResourceName,
Type = r.ResourceType,
Purpose = r.Purpose,
UploadTime = r.UploadTime,
ExamID = r.ExamID,
MimeType = r.MimeType
}).ToArray();
var mergedResourceInfos = allResources.Select(r => new ResourceInfo(r)).ToArray();
logger.Info($"成功获取资源列表,共 {mergedResourceInfos.Length} 个资源");
return Ok(mergedResourceInfos);
@@ -189,16 +160,7 @@ public class ResourceController : ControllerBase
return StatusCode(StatusCodes.Status500InternalServerError, $"获取资源列表失败: {result.Error.Message}");
}
var resources = result.Value.Select(r => new ResourceInfo
{
ID = r.ID.ToString(),
Name = r.ResourceName,
Type = r.ResourceType,
Purpose = r.Purpose,
UploadTime = r.UploadTime,
ExamID = r.ExamID,
MimeType = r.MimeType
}).ToArray();
var resources = result.Value.Select(r => new ResourceInfo(r)).ToArray();
logger.Info($"成功获取资源列表,共 {resources.Length} 个资源");
return Ok(resources);
@@ -221,7 +183,7 @@ public class ResourceController : ControllerBase
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult GetResourceById(string resourceId)
public IActionResult GetResourceById(Guid resourceId)
{
try
{
@@ -265,7 +227,7 @@ public class ResourceController : ControllerBase
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult DeleteResource(string resourceId)
public IActionResult DeleteResource(Guid resourceId)
{
try
{
@@ -317,67 +279,77 @@ public class ResourceController : ControllerBase
return StatusCode(StatusCodes.Status500InternalServerError, $"删除资源失败: {ex.Message}");
}
}
/// <summary>
/// 资源信息类
/// </summary>
public class ResourceInfo
{
/// <summary>
/// 资源ID
/// </summary>
public required string ID { get; set; }
/// <summary>
/// 资源名称
/// </summary>
public required string Name { get; set; }
/// <summary>
/// 资源类型
/// </summary>
public required string Type { get; set; }
/// <summary>
/// 资源用途template/user
/// </summary>
public required 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; }
}
/// <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; }
}
}
/// <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

@@ -18,7 +18,7 @@ public class VideoStreamController : ControllerBase
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly HttpVideoStreamService _videoStreamService;
private readonly Database.UserManager _userManager;
private readonly Database.UserManager _userManager = new();
public class AvailableResolutionsResponse
{
@@ -32,13 +32,10 @@ public class VideoStreamController : ControllerBase
/// 初始化HTTP视频流控制器
/// </summary>
/// <param name="videoStreamService">HTTP视频流服务</param>
/// <param name="userManager">用户管理服务</param>
public VideoStreamController(
HttpVideoStreamService videoStreamService, Database.UserManager userManager)
public VideoStreamController(HttpVideoStreamService videoStreamService)
{
logger.Info("创建VideoStreamController命名空间{Namespace}", this.GetType().Namespace);
_videoStreamService = videoStreamService;
_userManager = userManager;
}
private Optional<string> TryGetBoardId()
@@ -68,40 +65,6 @@ public class VideoStreamController : ControllerBase
return boardId.ToString();
}
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 db = new AppDataConnection();
if (db == null)
{
logger.Error("Database connection failed.");
return Optional<string>.None;
}
var userRet = db.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>
@@ -183,7 +146,7 @@ public class VideoStreamController : ControllerBase
}
[HttpPost("SetVideoStreamEnable")]
[ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(string), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(string), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> SetVideoStreamEnable(bool enable)
{
@@ -192,7 +155,7 @@ public class VideoStreamController : ControllerBase
var boardId = TryGetBoardId().OrThrow(() => new ArgumentException("Board ID is required"));
await _videoStreamService.SetVideoStreamEnableAsync(boardId.ToString(), enable);
return Ok($"HDMI transmission for board {boardId} disabled.");
return Ok($"HDMI transmission for board {boardId} {enable.ToString()}.");
}
catch (Exception ex)
{
@@ -414,12 +377,4 @@ public class VideoStreamController : ControllerBase
[Range(480, 1080, ErrorMessage = "高度必须在480-1080范围内")]
public int Height { get; set; }
}
public class AvailableResolutionsResponse
{
public int Width { get; set; }
public int Height { get; set; }
public string Name { get; set; } = string.Empty;
public string Value => $"{Width}x{Height}";
}
}

View File

@@ -8,12 +8,7 @@ public class ExamManager
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly AppDataConnection _db;
public ExamManager(AppDataConnection db)
{
this._db = db;
}
private AppDataConnection _db = new();
/// <summary>
/// 创建新实验

View File

@@ -9,12 +9,7 @@ public class ResourceManager
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly AppDataConnection _db;
public ResourceManager(AppDataConnection db)
{
this._db = db;
}
private readonly AppDataConnection _db = new();
/// <summary>
/// 根据文件扩展名获取MIME类型
@@ -140,7 +135,8 @@ public class ResourceManager
// 验证资源用途
if (resourcePurpose != ResourcePurpose.Template &&
resourcePurpose != ResourcePurpose.User)
resourcePurpose != ResourcePurpose.User &&
resourcePurpose != ResourcePurpose.Homework)
{
logger.Error($"无效的资源用途: {resourcePurpose}");
return new(new Exception($"无效的资源用途: {resourcePurpose}"));
@@ -154,7 +150,8 @@ public class ResourceManager
}
// 计算数据的SHA256
var sha256 = SHA256.HashData(data).ToString();
var sha256Bytes = SHA256.HashData(data);
var sha256 = Common.String.BytesToBase64(sha256Bytes);
if (string.IsNullOrEmpty(sha256))
{
logger.Error($"SHA256计算失败");
@@ -164,7 +161,7 @@ public class ResourceManager
var duplicateResource = _db.ResourceTable.Where(r => r.SHA256 == sha256).FirstOrDefault();
if (duplicateResource != null && duplicateResource.ResourceName == resourceName)
{
logger.Info($"资源已存在: {resourceName}");
logger.Info($"资源已存在: {resourceName}, ID: {duplicateResource.ID}, UserID: {duplicateResource.UserID}");
return duplicateResource;
}
@@ -216,7 +213,7 @@ public class ResourceManager
/// <param name="userId">用户ID可选</param>
/// </summary>
/// <returns>资源信息列表</returns>
public Result<(string ID, string Name)[]> GetResourceListByType(
public Result<Resource[]> GetResourceListByType(
string resourceType,
ResourcePurpose? resourcePurpose = null,
string? examId = null,
@@ -241,17 +238,14 @@ public class ResourceManager
query = query.Where(r => r.UserID == userId);
}
var resources = query
.Select(r => new { r.ID, r.ResourceName })
.ToArray();
var resources = query.ToArray();
var result = resources.Select(r => (r.ID.ToString(), r.ResourceName)).ToArray();
logger.Info($"获取资源列表: {resourceType}" +
(examId != null ? $"/{examId}" : "") +
($"/{resourcePurpose.ToString()}") +
(userId != null ? $"/{userId}" : "") +
$",共 {result.Length} 个资源");
return new(result);
$",共 {resources.Length} 个资源");
return new(resources);
}
catch (Exception ex)
{
@@ -319,9 +313,9 @@ public class ResourceManager
/// </summary>
/// <param name="resourceId">资源ID</param>
/// <returns>资源数据</returns>
public Optional<Resource> GetResourceById(string resourceId)
public Optional<Resource> GetResourceById(Guid resourceId)
{
var resource = _db.ResourceTable.Where(r => r.ID.ToString() == resourceId).FirstOrDefault();
var resource = _db.ResourceTable.Where(r => r.ID == resourceId).FirstOrDefault();
if (resource == null)
{
@@ -338,11 +332,11 @@ public class ResourceManager
/// </summary>
/// <param name="resourceId">资源ID</param>
/// <returns>删除的记录数</returns>
public Result<int> DeleteResource(string resourceId)
public Result<int> DeleteResource(Guid resourceId)
{
try
{
var result = _db.ResourceTable.Where(r => r.ID.ToString() == resourceId).Delete();
var result = _db.ResourceTable.Where(r => r.ID == resourceId).Delete();
logger.Info($"资源已删除: {resourceId},删除记录数: {result}");
return new(result);
}

View File

@@ -1,6 +1,7 @@
using DotNext;
using LinqToDB;
using LinqToDB.Mapping;
using Tapper;
namespace Database;
@@ -231,6 +232,7 @@ public class Exam
/// <summary>
/// 资源类型枚举
/// </summary>
[TranspilationSource]
public static class ResourceTypes
{
/// <summary>

View File

@@ -8,12 +8,7 @@ public class UserManager
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly AppDataConnection _db;
public UserManager(AppDataConnection db)
{
this._db = db;
}
private readonly AppDataConnection _db = new();
/// <summary>
/// 添加一个新的用户到数据库

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);
}
}
}

View File

@@ -30,15 +30,14 @@ 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;
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, Database.UserManager userManager)
public JtagHub(IHubContext<JtagHub, IJtagReceiver> hubContext)
{
_hubContext = hubContext;
_userManager = userManager;
}
private Optional<Peripherals.JtagClient.Jtag> GetJtagClient(string userName)
@@ -69,23 +68,26 @@ public class JtagHub : Hub<IJtagReceiver>, IJtagHub
public async Task<bool> SetBoundaryScanFreq(int freq)
{
try
return await Task.Run(() =>
{
var userName = Context.User?.FindFirstValue(ClaimTypes.Name);
if (userName is null)
try
{
logger.Error("Can't get user info");
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;
}
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)
@@ -99,7 +101,7 @@ public class JtagHub : Hub<IJtagReceiver>, IJtagHub
return false;
}
SetBoundaryScanFreq(freq);
await SetBoundaryScanFreq(freq);
var cts = new CancellationTokenSource();
CancellationTokenSourceTable.AddOrUpdate(userName, cts, (key, value) => cts);
@@ -145,23 +147,27 @@ public class JtagHub : Hub<IJtagReceiver>, IJtagHub
public async Task<bool> StopBoundaryScan()
{
var userName = Context.User?.FindFirstValue(ClaimTypes.Name);
if (userName is null)
return await Task.Run(() =>
{
logger.Error("No Such User");
return false;
}
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;
}
if (!CancellationTokenSourceTable.TryGetValue(userName, out var cts))
{
return false;
}
cts.Cancel();
cts.Token.WaitHandle.WaitOne();
cts.Cancel();
cts.Token.WaitHandle.WaitOne();
logger.Info($"Boundary scan stopped for user {userName}");
return true;
});
logger.Info($"Boundary scan stopped for user {userName}");
return true;
}
private async Task BoundaryScanLogicPorts(string connectionID, string userName, CancellationToken cancellationToken)

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

@@ -1,17 +1,20 @@
using Microsoft.AspNetCore.Authorization;
using System.Security.Claims;
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]
@@ -23,8 +26,7 @@ public interface IProgressReceiver
[TranspilationSource]
public enum ProgressStatus
{
Pending,
InProgress,
Running,
Completed,
Canceled,
Failed
@@ -33,10 +35,10 @@ public enum ProgressStatus
[TranspilationSource]
public class ProgressInfo
{
public string TaskId { get; }
public ProgressStatus Status { get; }
public int ProgressPercent { get; }
public string ErrorMessage { get; }
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]
@@ -44,18 +46,32 @@ public class ProgressInfo
public class ProgressHub : Hub<IProgressReceiver>, IProgressHub
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly IHubContext<ProgressHub, IProgressReceiver> _hubContext;
private readonly ProgressTrackerService _tracker;
public ProgressHub(IHubContext<ProgressHub, IProgressReceiver> hubContext, ProgressTrackerService tracker)
{
_hubContext = hubContext;
_tracker = tracker;
}
private readonly ProgressTracker _progressTracker = MsgBus.ProgressTracker;
public async Task<bool> Join(string taskId)
{
return _tracker.BindTask(taskId, Context.ConnectionId);
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

@@ -20,7 +20,7 @@ public class Camera
private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
readonly int timeout = 500;
readonly int taskID;
readonly int taskID = 8;
readonly int port;
readonly string address;
private IPEndPoint ep;

View File

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

View File

@@ -1,6 +1,5 @@
using System.Net;
using DotNext;
using Peripherals.PowerClient;
using WebProtocol;
namespace Peripherals.HdmiInClient;
@@ -8,8 +7,17 @@ namespace Peripherals.HdmiInClient;
static class HdmiInAddr
{
public const UInt32 BASE = 0xA000_0000;
public const UInt32 HdmiIn_CTRL = BASE + 0x0; //[0]: rstn, 0 is reset.
public const UInt32 HdmiIn_READFIFO = BASE + 0x1;
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
@@ -22,10 +30,9 @@ public class HdmiIn
readonly string address;
private IPEndPoint ep;
// 动态分辨率参数
private UInt16 _currentWidth = 960;
private UInt16 _currentHeight = 540;
private UInt32 _currentFrameLength = 960 * 540 * 2 / 4; // RGB565格式2字节/像素按4字节对齐
public int Width { get; private set; }
public int Height { get; private set; }
public int FrameLength => Width * Height / 2;
/// <summary>
/// 初始化HDMI输入客户端
@@ -45,9 +52,54 @@ public class HdmiIn
this.timeout = timeout;
}
public async ValueTask<Result<bool>> EnableTrans(bool isEnable)
public async ValueTask<Result<bool>> Init(bool enable = true)
{
var ret = await UDPClientPool.WriteAddr(this.ep, this.taskID, HdmiInAddr.HdmiIn_CTRL, (isEnable ? 0x00000001u : 0x00000000u));
{
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}");
@@ -76,9 +128,9 @@ public class HdmiIn
var result = await UDPClientPool.ReadAddr4BytesAsync(
this.ep,
this.taskID, // taskID
HdmiInAddr.HdmiIn_READFIFO,
(int)_currentFrameLength, // 使用当前分辨率的动态大小
BurstType.FixedBurst,
HdmiInAddr.ADDR_HDMI_WD_START,
FrameLength, // 使用当前分辨率的动态大小
BurstType.ExtendBurst,
this.timeout);
if (!result.IsSuccessful)
@@ -100,7 +152,7 @@ public class HdmiIn
return result.Value;
}
public async ValueTask<(byte[] header, byte[] data, byte[] footer)?> GetMJpegFrame()
public async ValueTask<Optional<(byte[] header, byte[] data, byte[] footer)>> GetMJpegFrame()
{
// 从HDMI读取RGB24数据
var readStartTime = DateTime.UtcNow;
@@ -111,55 +163,133 @@ public class HdmiIn
if (!frameResult.IsSuccessful || frameResult.Value == null)
{
logger.Warn("HDMI帧读取失败或为空");
return null;
return Optional<(byte[] header, byte[] data, byte[] footer)>.None;
}
var rgb24Data = frameResult.Value;
var rgb565Data = frameResult.Value;
// 验证数据长度是否正确 (RGB24为每像素2字节)
var expectedLength = _currentWidth * _currentHeight * 2;
if (rgb24Data.Length != expectedLength)
var expectedLength = Width * Height * 2;
if (rgb565Data.Length != expectedLength)
{
logger.Warn("HDMI数据长度不匹配期望: {Expected}, 实际: {Actual}",
expectedLength, rgb24Data.Length);
expectedLength, rgb565Data.Length);
}
// 将RGB24转换为JPEG参考Camera版本的处理
var jpegStartTime = DateTime.UtcNow;
var jpegResult = Common.Image.ConvertRGB24ToJpeg(rgb24Data, _currentWidth, _currentHeight, 80);
var jpegEndTime = DateTime.UtcNow;
var jpegTime = (jpegEndTime - jpegStartTime).TotalMilliseconds;
var jpegResult = Common.Image.ConvertRGB565ToJpeg(rgb565Data, Width, Height, 80, false);
if (!jpegResult.IsSuccessful)
{
logger.Error("HDMI RGB24转JPEG失败: {Error}", jpegResult.Error);
return null;
logger.Error("HDMI RGB565转JPEG失败: {Error}", jpegResult.Error);
return Optional<(byte[] header, byte[] data, byte[] footer)>.None;
}
var jpegData = jpegResult.Value;
// 发送MJPEG帧使用Camera版本的格式
var mjpegFrameHeader = Common.Image.CreateMjpegFrameHeader(jpegData.Length);
var mjpegFrameFooter = Common.Image.CreateMjpegFrameFooter();
return (mjpegFrameHeader, jpegData, mjpegFrameFooter);
}
/// <summary>
/// 获取当前分辨率
/// </summary>
/// <returns>当前分辨率(宽度, 高度)</returns>
public (int Width, int Height) GetCurrentResolution()
public async ValueTask<Result<bool>> CheckHdmiIsReady()
{
return (_currentWidth, _currentHeight);
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;
}
/// <summary>
/// 获取当前帧长度
/// </summary>
/// <returns>当前帧长度</returns>
public UInt32 GetCurrentFrameLength()
public async ValueTask<Result<(int, int)>> GetHdmiResolution()
{
return _currentFrameLength;
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

@@ -6,7 +6,7 @@ namespace Peripherals.JpegClient;
static class JpegAddr
{
const UInt32 BASE = 0x0000_0000;
const UInt32 BASE = 0xA000_0000;
public const UInt32 CAPTURE_RD_CTRL = BASE + 0x0;
public const UInt32 CAPTURE_WR_CTRL = BASE + 0x1;
@@ -26,9 +26,11 @@ static class JpegAddr
public const UInt32 JPEG_FRAME_SAVE_NUM = BASE + 0xC;
public const UInt32 JPEG_FIFO_FRAME_INFO = BASE + 0xD;
public const UInt32 ADDR_HDMI_WD_START = 0x4000_0000;
public const UInt32 ADDR_JPEG_START = 0x8000_0000;
public const UInt32 ADDR_JPEG_END = 0xA000_0000;
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
@@ -82,6 +84,9 @@ public class Jpeg
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)
@@ -139,23 +144,33 @@ public class Jpeg
else return true;
}
public async ValueTask<bool> SetEnable(bool enable)
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 false;
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;
}
}
return ret.Value;
{
var ret = await AddFrameNum2Process(1);
if (!ret)
{
logger.Error($"Failed to AddFrameNum2Process: {ret}");
return ret.Value;
}
}
return true;
}
else
{
@@ -178,7 +193,7 @@ public class Jpeg
public async ValueTask<Result<bool>> CheckHdmiIsReady()
{
var ret = await UDPClientPool.ReadAddrWithWait(
this.ep, this.taskID, JpegAddr.HDMI_NOT_READY, 0b01, 0b01, 100, this.timeout);
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}");
@@ -189,8 +204,8 @@ public class Jpeg
public async ValueTask<Result<(int, int)>> GetHdmiResolution()
{
var ret = await UDPClientPool.ReadAddr(
this.ep, this.taskID, JpegAddr.HDMI_HEIGHT_WIDTH, 0, this.timeout);
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}");
@@ -204,8 +219,13 @@ public class Jpeg
return new(new Exception("Invalid HDMI resolution data length"));
}
var width = data[0] | (data[1] << 8);
var height = data[2] | (data[3] << 8);
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));
}
@@ -217,7 +237,22 @@ public class Jpeg
return new(new ArgumentException("Invalid HDMI resolution"));
}
var frameSize = (UInt32)(width * height / 4);
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(
@@ -237,7 +272,7 @@ public class Jpeg
{
var ret = await UDPClientPool.WriteAddr(
this.ep, this.taskID, JpegAddr.END_WR_ADDR0,
JpegAddr.ADDR_HDMI_WD_START + frameSize, this.timeout);
JpegAddr.ADDR_HDMI_WD_START + frameSize - 1, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to set HDMI output end address: {ret.Error}");
@@ -268,7 +303,7 @@ public class Jpeg
{
var ret = await UDPClientPool.WriteAddr(
this.ep, this.taskID, JpegAddr.END_RD_ADDR0,
JpegAddr.ADDR_HDMI_WD_START + frameSize, this.timeout);
JpegAddr.ADDR_HDMI_WD_START + frameSize - 1, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to set jpeg input end address: {ret.Error}");
@@ -333,14 +368,39 @@ public class Jpeg
public async ValueTask<uint> GetFrameNumber()
{
var ret = await UDPClientPool.ReadAddrByte(
this.ep, this.taskID, JpegAddr.JPEG_FRAME_SAVE_NUM, this.timeout);
if (!ret.IsSuccessful)
const int maxAttempts = 10;
const int delayMs = 5;
for (int attempt = 0; attempt < maxAttempts; attempt++)
{
logger.Error($"Failed to get JPEG frame number: {ret.Error}");
return 0;
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);
}
}
return Number.BytesToUInt32(ret.Value.Options.Data ?? Array.Empty<byte>()).Value;
// 所有尝试都失败或返回0
return 0;
}
public async ValueTask<Optional<List<JpegInfo>>> GetFrameInfo(int num)
@@ -373,14 +433,14 @@ public class Jpeg
return new(infos);
}
public async ValueTask<bool> AddFrameNum2Process(uint cnt)
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 false;
return ret.Value;
}
return ret.Value;
}
@@ -504,4 +564,45 @@ public class Jpeg
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

@@ -380,10 +380,12 @@ 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;
/// <summary>
@@ -392,6 +394,7 @@ public class Jtag
public readonly string address;
private IPEndPoint ep;
/// <summary>
/// Jtag 构造函数
/// </summary>
@@ -413,7 +416,7 @@ public class Jtag
{
BurstType = BurstType.FixedBurst,
BurstLength = 0,
CommandID = 0,
CommandID = (byte)this.taskID,
Address = devAddr,
IsWrite = false,
};
@@ -427,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"));
@@ -444,10 +447,10 @@ public class Jtag
async ValueTask<Result<bool>> WriteFIFO(
UInt32 devAddr, UInt32 data, UInt32 result,
UInt32 resultMask = 0xFF_FF_FF_FF, UInt32 delayMilliseconds = 0, ProgressReporter? progress = null)
UInt32 resultMask = 0xFF_FF_FF_FF, UInt32 delayMilliseconds = 0, string progressId = "")
{
{
var ret = await UDPClientPool.WriteAddr(this.ep, 0, devAddr, data, this.timeout, progress?.CreateChild(80));
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"));
}
@@ -456,19 +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);
progress?.Finish();
_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, ProgressReporter? progress = null)
UInt32 resultMask = 0xFF_FF_FF_FF, UInt32 delayMilliseconds = 0, string progressId = "")
{
{
var ret = await UDPClientPool.WriteAddr(this.ep, 0, devAddr, data, this.timeout, progress?.CreateChild(80));
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"));
}
@@ -477,9 +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);
progress?.Finish();
_progressTracker.AdvanceProgress(progressId, 10);
return ret.Value;
}
}
@@ -564,7 +568,7 @@ public class Jtag
}
async ValueTask<Result<bool>> LoadDRCareInput(
byte[] bytesArray, UInt32 timeout = 10_000, UInt32 cycle = 500, ProgressReporter? progress = null)
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)"));
@@ -579,14 +583,15 @@ public class Jtag
else if (!ret.Value) return new(new Exception("Write CMD_JTAG_LOAD_DR_CAREI Failed"));
}
progress?.Report(10);
_progressTracker.AdvanceProgress(progressId, 10);
{
var ret = await WriteFIFO(
JtagAddr.WRITE_DATA,
bytesArray, 0x01_00_00_00,
JtagState.CMD_EXEC_FINISH,
progress: progress?.CreateChild(90)
0,
progressId
);
if (!ret.IsSuccessful) return new(ret.Error);
@@ -621,7 +626,7 @@ public class Jtag
if (ret.Value)
{
var array = new UInt32[UInt32Num];
var retData = await UDPClientPool.ReadAddr4Bytes(ep, 0, JtagAddr.READ_DATA, (int)UInt32Num);
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);
@@ -638,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;
@@ -676,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;
@@ -709,56 +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, ProgressReporter? progress = null)
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");
if (progress != null)
{
progress.ExpectedSteps = 25;
progress.Increase();
}
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"));
progress?.Increase();
_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"));
progress?.Increase();
_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"));
progress?.Increase();
_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"));
progress?.Increase();
_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"));
progress?.Increase();
_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"));
progress?.Increase();
_progressTracker.AdvanceProgress(progressId, 10);
ret = await LoadDRCareInput(bitstream, progress: progress?.CreateChild(50));
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"));
@@ -767,40 +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"));
progress?.Increase();
_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"));
progress?.Increase();
_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"));
progress?.Increase();
_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"));
progress?.Increase();
_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"));
progress?.Increase();
_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");
progress?.Increase();
_progressTracker.AdvanceProgress(progressId, 10);
// Finish
progress?.Finish();
_progressTracker.AdvanceProgress(progressId, 10);
return true;
}
@@ -816,9 +820,9 @@ public class Jtag
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;
@@ -883,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

@@ -9,7 +9,7 @@ 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>
@@ -68,9 +68,9 @@ static class AnalyzerAddr
public const UInt32 PRE_LOAD_NUM_ADDR = BASE + 0x0000_0003;
public const UInt32 CAHNNEL_DIV_ADDR = BASE + 0x0000_0004;
public const UInt32 CLOCK_DIV_ADDR = BASE + 0x0000_0005;
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 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>
@@ -327,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;
@@ -472,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"));
}
}
{

View File

@@ -2,9 +2,20 @@ 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;
@@ -24,40 +35,45 @@ static class OscilloscopeAddr
/// </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>
/// 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>
/// 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>
/// 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>
/// 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>
/// 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>
/// 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>
/// 0x0000_1000-0x0000_13FF:R[7:0] wave_rd_data 共1024个字节
@@ -66,12 +82,12 @@ static class OscilloscopeAddr
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;
@@ -83,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));
@@ -93,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>
@@ -165,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;
}
@@ -315,6 +360,23 @@ 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,
@@ -345,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

@@ -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

@@ -17,26 +17,24 @@ public class HdmiVideoStreamClient
{
public required HdmiIn HdmiInClient { get; set; }
public required Jpeg JpegClient { 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 readonly IServiceProvider _serviceProvider;
private HttpListener? _httpListener;
private readonly int _serverPort = 4322;
private readonly ConcurrentDictionary<string, HdmiVideoStreamClient> _clientDict = new();
public HttpHdmiVideoStreamService(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public override async Task StartAsync(CancellationToken cancellationToken)
{
_httpListener = new HttpListener();
@@ -80,7 +78,6 @@ public class HttpHdmiVideoStreamService : BackgroundService
public override async Task StopAsync(CancellationToken cancellationToken)
{
logger.Info("Stopping HDMI Video Stream Service...");
_httpListener?.Close();
// 禁用所有活跃的HDMI传输
var disableTasks = new List<Task>();
@@ -95,7 +92,6 @@ public class HttpHdmiVideoStreamService : BackgroundService
// 清空字典
_clientDict.Clear();
_httpListener?.Close(); // 立即关闭监听器,唤醒阻塞
await base.StopAsync(cancellationToken);
}
@@ -106,15 +102,18 @@ public class HttpHdmiVideoStreamService : BackgroundService
var client = _clientDict[key];
client.CTS.Cancel();
var disableResult = await client.HdmiInClient.EnableTrans(false);
if (disableResult.IsSuccessful)
// 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: {disableResult.Error}");
logger.Error($"Failed to disable HDMI transmission");
}
client.CTS = new CancellationTokenSource();
}
catch (Exception ex)
{
@@ -124,31 +123,32 @@ public class HttpHdmiVideoStreamService : BackgroundService
private async Task<HdmiVideoStreamClient?> GetOrCreateClientAsync(string boardId)
{
if (_clientDict.TryGetValue(boardId, out var client)) return client;
using var scope = _serviceProvider.CreateScope();
var userManager = scope.ServiceProvider.GetRequiredService<Database.UserManager>();
var boardRet = userManager.GetBoardByID(Guid.Parse(boardId));
if (!boardRet.IsSuccessful || !boardRet.Value.HasValue)
if (!_clientDict.TryGetValue(boardId, out var client))
{
logger.Error($"Failed to get board with ID {boardId}");
return null;
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
};
}
var board = boardRet.Value.Value;
client = new HdmiVideoStreamClient()
{
HdmiInClient = new HdmiIn(board.IpAddr, board.Port, 1),
JpegClient = new Jpeg(board.IpAddr, board.Port, 1),
CTS = new CancellationTokenSource()
};
// 启用HDMI传输
try
{
var hdmiEnableRet = await client.HdmiInClient.EnableTrans(true);
var hdmiEnableRet = await client.HdmiInClient.Init(true);
if (!hdmiEnableRet.IsSuccessful)
{
logger.Error($"Failed to enable HDMI transmission for board {boardId}: {hdmiEnableRet.Error}");
@@ -156,13 +156,18 @@ public class HttpHdmiVideoStreamService : BackgroundService
}
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}");
// 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)
{
@@ -191,20 +196,16 @@ public class HttpHdmiVideoStreamService : BackgroundService
return;
}
var hdmiInToken = _clientDict[boardId].CTS.Token;
if (hdmiInToken == null)
{
await SendErrorAsync(context.Response, "HDMI input is not available");
return;
}
var token = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken, client.CTS.Token).Token;
if (path == "/snapshot")
{
await HandleSnapshotRequestAsync(context.Response, client, hdmiInToken);
await HandleSnapshotRequestAsync(context.Response, client, token);
}
else if (path == "/mjpeg")
{
await HandleMjpegStreamAsync(context.Response, client, hdmiInToken);
await HandleMjpegStreamAsync(context.Response, client, token);
}
else if (path == "/video")
{
@@ -224,28 +225,61 @@ public class HttpHdmiVideoStreamService : BackgroundService
logger.Debug("处理HDMI快照请求");
// 从HDMI读取RGB565数据
var frameResult = await client.HdmiInClient.ReadFrame();
if (!frameResult.IsSuccessful || frameResult.Value == null)
// 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快照获取失败");
logger.Error("获取HDMI MJPEG帧失败");
response.StatusCode = 500;
var errorBytes = System.Text.Encoding.UTF8.GetBytes("Failed to get HDMI snapshot");
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;
}
var jpegData = frameResult.Value;
// 设置响应头参考Camera版本
response.ContentType = "image/jpeg";
response.ContentLength64 = jpegData.Length;
response.ContentLength64 = jpegImage.Value.data.Length;
response.Headers.Add("Cache-Control", "no-cache, no-store, must-revalidate");
await response.OutputStream.WriteAsync(jpegData, 0, jpegData.Length, cancellationToken);
await response.OutputStream.WriteAsync(jpegImage.Value.data, 0, jpegImage.Value.data.Length, cancellationToken);
await response.OutputStream.FlushAsync(cancellationToken);
logger.Debug("已发送HDMI快照图像大小{Size} 字节", jpegData.Length);
logger.Debug("已发送HDMI快照图像大小{Size} 字节", jpegImage.Value.data.Length);
}
catch (Exception ex)
{
@@ -254,6 +288,7 @@ public class HttpHdmiVideoStreamService : BackgroundService
}
finally
{
response.StatusCode = 200;
response.Close();
}
}
@@ -271,38 +306,92 @@ public class HttpHdmiVideoStreamService : BackgroundService
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)
{
try
var frameStartTime = DateTime.UtcNow;
var frameRet = await client.HdmiInClient.GetMJpegFrame();
if (!frameRet.HasValue)
{
var frameStartTime = DateTime.UtcNow;
var ret = await client.HdmiInClient.GetMJpegFrame();
if (ret == null) continue;
var frame = ret.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);
}
logger.Error("获取HDMI帧失败");
continue;
}
catch (Exception ex)
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.Error(ex, "处理HDMI帧时发生错误");
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)
@@ -314,7 +403,7 @@ public class HttpHdmiVideoStreamService : BackgroundService
try
{
// 停止传输时禁用HDMI传输
await client.HdmiInClient.EnableTrans(false);
await client.HdmiInClient.SetTransEnable(false);
logger.Info("已禁用HDMI传输");
}
catch (Exception ex)
@@ -369,8 +458,7 @@ public class HttpHdmiVideoStreamService : BackgroundService
/// <returns>返回所有可用的HDMI视频流终端点列表</returns>
public List<HdmiVideoStreamEndpoint>? GetAllVideoEndpoints()
{
using var scope = _serviceProvider.CreateScope();
var userManager = scope.ServiceProvider.GetRequiredService<Database.UserManager>();
var userManager = new Database.UserManager();
var boards = userManager.GetAllBoard();
if (boards == null)

View File

@@ -4,10 +4,6 @@ using System.Collections.Concurrent;
using DotNext;
using DotNext.Threading;
#if USB_CAMERA
using OpenCvSharp;
#endif
namespace server.Services;
public class VideoStreamClient
@@ -17,17 +13,17 @@ public class VideoStreamClient
public int FrameWidth { get; set; }
public int FrameHeight { get; set; }
public int FrameRate { get; set; }
public Peripherals.CameraClient.Camera Camera { get; set; }
public AsyncLazy<Peripherals.CameraClient.Camera> Camera { get; set; }
public CancellationTokenSource CTS { get; set; }
public readonly AsyncReaderWriterLock Lock = new();
public VideoStreamClient(
string clientId, int width, int height, Peripherals.CameraClient.Camera camera)
string clientId, int width, int height, AsyncLazy<Peripherals.CameraClient.Camera> camera)
{
ClientId = clientId;
FrameWidth = width;
FrameHeight = height;
FrameRate = 0;
FrameRate = 30;
Camera = camera;
CTS = new CancellationTokenSource();
}
@@ -95,35 +91,39 @@ public class HttpVideoStreamService : BackgroundService
{
private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly IServiceProvider _serviceProvider;
private HttpListener? _httpListener;
private readonly int _serverPort = 4321;
private readonly ConcurrentDictionary<string, VideoStreamClient> _clientDict = new();
// USB Camera 相关
#if USB_CAMERA
private VideoCapture? _usbCamera;
private bool _usbCameraEnable = false;
private readonly object _usbCameraLock = new object();
#endif
private AsyncLazy<UsbCameraCapture> _usbCamera = new(async token => await InitializeUsbCamera(token));
public HttpVideoStreamService(IServiceProvider serviceProvider)
private static async Task<UsbCameraCapture> InitializeUsbCamera(CancellationToken token)
{
_serviceProvider = serviceProvider;
try
{
var camera = new UsbCameraCapture();
var devices = camera.GetDevices();
for (int i = 0; i < devices.Count; i++)
logger.Info($"Device[{i}]: {devices[i].Name}");
await camera.StartAsync(1, 2592, 1994, 30);
return camera;
}
catch (Exception ex)
{
logger.Error(ex, "Failed to start USB camera");
throw;
}
}
private Optional<VideoStreamClient> TryGetClient(string boardId)
{
if (_clientDict.TryGetValue(boardId, out var client))
{
return client;
}
return null;
return _clientDict.TryGetValue(boardId, out var client) ? client : null;
}
private async Task<VideoStreamClient?> GetOrCreateClientAsync(string boardId, int initWidth, int initHeight)
private Optional<VideoStreamClient> GetOrCreateClient(
string boardId, int initWidth, int initHeight)
{
if (_clientDict.TryGetValue(boardId, out var client))
{
@@ -131,8 +131,7 @@ public class HttpVideoStreamService : BackgroundService
return client;
}
using var scope = _serviceProvider.CreateScope();
var userManager = scope.ServiceProvider.GetRequiredService<Database.UserManager>();
var userManager = new Database.UserManager();
var boardRet = userManager.GetBoardByID(Guid.Parse(boardId));
if (!boardRet.IsSuccessful || !boardRet.Value.HasValue)
@@ -143,13 +142,17 @@ public class HttpVideoStreamService : BackgroundService
var board = boardRet.Value.Value;
var camera = new Peripherals.CameraClient.Camera(board.IpAddr, board.Port);
var ret = await camera.Init();
if (!ret.IsSuccessful || !ret.Value)
var camera = new AsyncLazy<Peripherals.CameraClient.Camera>(async (_) =>
{
logger.Error("Camera Init Failed!");
return null;
}
var camera = new Peripherals.CameraClient.Camera(board.IpAddr, board.Port);
var ret = await camera.Init();
if (!ret.IsSuccessful || !ret.Value)
{
logger.Error("Camera Init Failed!");
throw new Exception("Camera Init Failed!");
}
return camera;
});
client = new VideoStreamClient(boardId, initWidth, initHeight, camera);
_clientDict[boardId] = client;
@@ -178,9 +181,12 @@ public class HttpVideoStreamService : BackgroundService
{
var client = _clientDict[clientKey];
client.CTS.Cancel();
if (!client.Camera.IsValueCreated) continue;
using (await client.Lock.AcquireWriteLockAsync(cancellationToken))
{
await client.Camera.EnableHardwareTrans(false);
var camera = await client.Camera.WithCancellation(cancellationToken);
await camera.EnableHardwareTrans(false);
}
}
_clientDict.Clear();
@@ -225,44 +231,46 @@ public class HttpVideoStreamService : BackgroundService
private async Task HandleRequestAsync(HttpListenerContext context, CancellationToken cancellationToken)
{
var path = context.Request.Url?.AbsolutePath ?? "/";
var boardId = context.Request.QueryString["board"];
var width = int.TryParse(context.Request.QueryString["width"], out var w) ? w : 640;
var height = int.TryParse(context.Request.QueryString["height"], out var h) ? h : 480;
var boardId = context.Request.QueryString["boardId"];
if (string.IsNullOrEmpty(boardId))
{
await SendErrorAsync(context.Response, "Missing clientId");
return;
}
var client = await GetOrCreateClientAsync(boardId, width, height);
if (client == null)
var width = int.TryParse(context.Request.QueryString["width"], out var w) ? w : 640;
var height = int.TryParse(context.Request.QueryString["height"], out var h) ? h : 480;
var clientOpt = GetOrCreateClient(boardId, width, height);
if (!clientOpt.HasValue)
{
await SendErrorAsync(context.Response, "Invalid clientId or camera not available");
return;
}
var clientToken = client.CTS.Token;
var client = clientOpt.Value;
var token = CancellationTokenSource.CreateLinkedTokenSource(
client.CTS.Token, cancellationToken).Token;
try
{
token.ThrowIfCancellationRequested();
logger.Info("新HTTP客户端连接: {RemoteEndPoint}", context.Request.RemoteEndPoint);
if (path == "/video-stream")
if (path == "/video")
{
// MJPEG 流请求FPGA
await HandleMjpegStreamAsync(context.Response, client, cancellationToken);
await HandleMjpegStreamAsync(context.Response, client, token);
}
#if USB_CAMERA
else if (requestPath == "/usb-camera")
else if (path == "/usbCamera")
{
// USB Camera MJPEG流请求
await HandleUsbCameraStreamAsync(response, cancellationToken);
await HandleUsbCameraStreamAsync(context.Response, client, token);
}
#endif
else if (path == "/snapshot")
{
// 单帧图像请求
await HandleSnapshotRequestAsync(context.Response, client, cancellationToken);
await HandleSnapshotRequestAsync(context.Response, client, token);
}
else if (path == "/html")
{
@@ -289,24 +297,32 @@ public class HttpVideoStreamService : BackgroundService
}
// USB Camera MJPEG流处理
#if USB_CAMERA
private async Task HandleUsbCameraStreamAsync(HttpListenerResponse response, CancellationToken cancellationToken)
private async Task HandleUsbCameraStreamAsync(
HttpListenerResponse response, VideoStreamClient client, CancellationToken cancellationToken)
{
var camera = await _usbCamera.WithCancellation(cancellationToken);
Action<byte[]> frameHandler = async (jpegData) =>
{
try
{
var header = Encoding.ASCII.GetBytes("--boundary\r\nContent-Type: image/jpeg\r\nContent-Length: " + jpegData.Length + "\r\n\r\n");
await response.OutputStream.WriteAsync(header, 0, header.Length, cancellationToken);
await response.OutputStream.WriteAsync(jpegData, 0, jpegData.Length, cancellationToken);
await response.OutputStream.WriteAsync(new byte[] { 0x0D, 0x0A }, 0, 2, cancellationToken); // \r\n
await response.OutputStream.FlushAsync(cancellationToken);
}
catch
{
logger.Error("Error sending MJPEG frame");
}
};
try
{
lock (_usbCameraLock)
{
if (_usbCamera == null)
{
_usbCamera = new VideoCapture(1);
_usbCamera.Fps = _frameRate;
_usbCamera.FrameWidth = _frameWidth;
_usbCamera.FrameHeight = _frameHeight;
_usbCameraEnable = _usbCamera.IsOpened();
}
}
if (!_usbCameraEnable || _usbCamera == null || !_usbCamera.IsOpened())
if (!camera.IsCapturing)
{
logger.Error("USB Camera is not capturing");
response.StatusCode = 500;
await response.OutputStream.FlushAsync(cancellationToken);
response.Close();
@@ -318,61 +334,38 @@ public class HttpVideoStreamService : BackgroundService
response.Headers.Add("Pragma", "no-cache");
response.Headers.Add("Expires", "0");
using (var mat = new Mat())
logger.Info("Start USB Camera MJPEG Stream");
camera.FrameReady += frameHandler;
while (true)
{
while (!cancellationToken.IsCancellationRequested)
{
bool grabbed;
lock (_usbCameraLock)
{
grabbed = _usbCamera.Read(mat);
}
if (!grabbed || mat.Empty())
{
await Task.Delay(50, cancellationToken);
continue;
}
// 编码为JPEG
byte[]? jpegData = null;
try
{
jpegData = mat.ToBytes(".jpg", new int[] { (int)ImwriteFlags.JpegQuality, 80 });
}
catch (Exception ex)
{
logger.Error(ex, "USB Camera帧编码JPEG失败");
continue;
}
if (jpegData == null)
continue;
// MJPEG帧头
var header = Encoding.ASCII.GetBytes("--boundary\r\nContent-Type: image/jpeg\r\nContent-Length: " + jpegData.Length + "\r\n\r\n");
await response.OutputStream.WriteAsync(header, 0, header.Length, cancellationToken);
await response.OutputStream.WriteAsync(jpegData, 0, jpegData.Length, cancellationToken);
await response.OutputStream.WriteAsync(new byte[] { 0x0D, 0x0A }, 0, 2, cancellationToken); // \r\n
await response.OutputStream.FlushAsync(cancellationToken);
await Task.Delay(1000 / _frameRate, cancellationToken);
}
cancellationToken.ThrowIfCancellationRequested();
await Task.Delay(-1, cancellationToken);
}
}
catch (OperationCanceledException)
{
logger.Info("USB Camera MJPEG 串流取消");
}
catch (Exception ex)
{
logger.Error(ex, "USB Camera MJPEG流处理异常");
}
finally
{
camera.FrameReady -= frameHandler;
logger.Info("Usb Camera Stream Stopped");
try { response.Close(); } catch { }
}
}
#endif
private async Task HandleSnapshotRequestAsync(HttpListenerResponse response, VideoStreamClient client, CancellationToken cancellationToken)
private async Task HandleSnapshotRequestAsync(
HttpListenerResponse response, VideoStreamClient client, CancellationToken cancellationToken)
{
// 读取 Camera 快照,返回 JPEG
var frameResult = await client.Camera.ReadFrame();
var camera = await client.Camera.WithCancellation(cancellationToken);
var frameResult = await camera.ReadFrame();
if (!frameResult.IsSuccessful || frameResult.Value == null)
{
response.StatusCode = 500;
@@ -394,16 +387,18 @@ public class HttpVideoStreamService : BackgroundService
response.Close();
}
private async Task HandleMjpegStreamAsync(HttpListenerResponse response, VideoStreamClient client, CancellationToken cancellationToken)
private async Task HandleMjpegStreamAsync(
HttpListenerResponse response, VideoStreamClient client, CancellationToken cancellationToken)
{
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");
var camera = await client.Camera.WithCancellation(cancellationToken);
while (!cancellationToken.IsCancellationRequested)
{
var frameResult = await client.Camera.ReadFrame();
var frameResult = await camera.ReadFrame();
if (!frameResult.IsSuccessful || frameResult.Value == null) continue;
var jpegResult = Common.Image.ConvertRGB24ToJpeg(frameResult.Value, client.FrameWidth, client.FrameHeight, 80);
if (!jpegResult.IsSuccessful) continue;
@@ -516,7 +511,8 @@ public class HttpVideoStreamService : BackgroundService
{
// 从摄像头读取帧数据
var readStartTime = DateTime.UtcNow;
var result = await client.Camera.ReadFrame();
var camera = await client.Camera.WithCancellation(cancellationToken);
var result = await camera.ReadFrame();
var readEndTime = DateTime.UtcNow;
var readTime = (readEndTime - readStartTime).TotalMilliseconds;
@@ -576,7 +572,7 @@ public class HttpVideoStreamService : BackgroundService
using (await client.Lock.AcquireWriteLockAsync(TimeSpan.FromMilliseconds(timeout), cancellationToken))
{
var currentCamera = client.Camera;
var currentCamera = await client.Camera.WithCancellation(cancellationToken);
if (currentCamera == null)
{
var message = $"获取摄像头失败";
@@ -629,7 +625,8 @@ public class HttpVideoStreamService : BackgroundService
using (await client.Lock.AcquireWriteLockAsync(
TimeSpan.FromMilliseconds(timeout), cancellationToken))
{
var result = await client.Camera.InitAutoFocus();
var camera = await client.Camera.WithCancellation(cancellationToken);
var result = await camera.InitAutoFocus();
if (result.IsSuccessful && result.Value)
{
@@ -663,7 +660,8 @@ public class HttpVideoStreamService : BackgroundService
logger.Info($"Board{boardId}开始执行摄像头自动对焦");
var result = await client.Camera.PerformAutoFocus();
var camera = await client.Camera.WithCancellation(cancellationToken);
var result = await camera.PerformAutoFocus();
if (result.IsSuccessful && result.Value)
{
@@ -687,16 +685,18 @@ public class HttpVideoStreamService : BackgroundService
/// 配置摄像头连接参数
/// </summary>
/// <param name="boardId">板卡ID</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>配置是否成功</returns>
public async Task<bool> ConfigureCameraAsync(string boardId)
public async Task<bool> ConfigureCameraAsync(string boardId, CancellationToken cancellationToken = default)
{
try
{
var client = TryGetClient(boardId).OrThrow(() => new Exception($"无法获取摄像头客户端: {boardId}"));
var camera = await client.Camera.WithCancellation(cancellationToken);
using (await client.Lock.AcquireWriteLockAsync())
using (await client.Lock.AcquireWriteLockAsync(cancellationToken))
{
var ret = await client.Camera.Init();
var ret = await camera.Init();
if (!ret.IsSuccessful)
{
logger.Error(ret.Error);
@@ -710,9 +710,9 @@ public class HttpVideoStreamService : BackgroundService
}
}
using (await client.Lock.AcquireWriteLockAsync())
using (await client.Lock.AcquireWriteLockAsync(cancellationToken))
{
var ret = await client.Camera.ChangeResolution(client.FrameWidth, client.FrameHeight);
var ret = await camera.ChangeResolution(client.FrameWidth, client.FrameHeight);
if (!ret.IsSuccessful)
{
logger.Error(ret.Error);
@@ -746,16 +746,15 @@ public class HttpVideoStreamService : BackgroundService
using (await client.Lock.AcquireWriteLockAsync())
{
if (enable)
{
client.CTS = new CancellationTokenSource();
}
else
if (!enable || client.CTS.IsCancellationRequested)
{
client.CTS.Cancel();
client.CTS = new CancellationTokenSource();
}
var camera = client.Camera;
if (!client.Camera.IsValueCreated) return;
var camera = await client.Camera.WithCancellation(client.CTS.Token);
var disableResult = await camera.EnableHardwareTrans(enable);
if (disableResult.IsSuccessful && disableResult.Value)
logger.Info($"Successfully disabled camera {boardId} hardware transmission");
@@ -765,7 +764,7 @@ public class HttpVideoStreamService : BackgroundService
}
catch (Exception ex)
{
logger.Error(ex, $"Exception occurred while disabling HDMI transmission for camera {boardId}");
logger.Error(ex, $"Exception occurred while disabling video transmission for {boardId}");
}
}
@@ -790,7 +789,7 @@ public class HttpVideoStreamService : BackgroundService
public VideoStreamEndpoint GetVideoEndpoint(string boardId)
{
var client = TryGetClient(boardId).OrThrow(() => new Exception($"无法获取摄像头客户端: {boardId}"));
var client = GetOrCreateClient(boardId, 640, 480).OrThrow(() => new Exception($"无法获取摄像头客户端: {boardId}"));
return new VideoStreamEndpoint
{

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

@@ -1,288 +0,0 @@
using Microsoft.AspNetCore.SignalR;
using System.Collections.Concurrent;
using DotNext;
using Common;
using server.Hubs;
namespace server.Services;
public class ProgressReporter : ProgressInfo, IProgress<int>
{
private int _progress = 0;
private int _stepProgress = 1;
private int _expectedSteps = 100;
private int _parentProportion = 100;
public int Progress => _progress;
public int MaxProgress { get; set; } = 100;
public int StepProgress
{
get => _stepProgress;
set
{
_stepProgress = value;
_expectedSteps = MaxProgress / value;
}
}
public int ExpectedSteps
{
get => _expectedSteps;
set
{
_expectedSteps = value;
MaxProgress = Number.IntPow(10, Number.GetLength(value));
_stepProgress = MaxProgress / value;
}
}
public Func<int, Task>? ReporterFunc { get; set; } = null;
public ProgressReporter? Parent { get; set; }
public ProgressReporter? Child { get; set; }
private ProgressStatus _status = ProgressStatus.Pending;
private string _errorMessage;
public string TaskId { get; set; } = Guid.NewGuid().ToString();
public int ProgressPercent => _progress * 100 / MaxProgress;
public ProgressStatus Status => _status;
public string ErrorMessage => _errorMessage;
public ProgressReporter(Func<int, Task>? reporter = null, int initProgress = 0, int maxProgress = 100, int step = 1)
{
_progress = initProgress;
MaxProgress = maxProgress;
StepProgress = step;
ReporterFunc = reporter;
}
public ProgressReporter(int parentProportion, int expectedSteps = 100, Func<int, Task>? reporter = null)
{
this._parentProportion = parentProportion;
MaxProgress = Number.IntPow(10, Number.GetLength(expectedSteps));
StepProgress = MaxProgress / expectedSteps;
ReporterFunc = reporter;
}
private async void ForceReport(int value)
{
try
{
if (ReporterFunc != null)
await ReporterFunc(value);
if (Parent != null)
Parent.Increase((value - _progress) / StepProgress * _parentProportion / (MaxProgress / StepProgress));
_progress = value;
}
catch (OperationCanceledException ex)
{
_errorMessage = ex.Message;
this._status = ProgressStatus.Canceled;
}
catch (Exception ex)
{
_errorMessage = ex.Message;
this._status = ProgressStatus.Failed;
}
}
public async void Report(int value)
{
if (this._status == ProgressStatus.Pending)
this._status = ProgressStatus.InProgress;
else if (this.Status != ProgressStatus.InProgress)
return;
if (value > MaxProgress) return;
ForceReport(value);
}
public void Increase(int? value = null)
{
if (this._status == ProgressStatus.Pending)
this._status = ProgressStatus.InProgress;
else if (this.Status != ProgressStatus.InProgress)
return;
if (value.HasValue)
{
if (_progress + value.Value >= MaxProgress) return;
this.Report(_progress + value.Value);
}
else
{
if (_progress + StepProgress >= MaxProgress) return;
this.Report(_progress + StepProgress);
}
}
public void Finish()
{
this._status = ProgressStatus.Completed;
this.ForceReport(MaxProgress);
}
public void Cancel()
{
this._status = ProgressStatus.Canceled;
this._errorMessage = "User Cancelled";
this.ForceReport(_progress);
}
public void Error(string message)
{
this._status = ProgressStatus.Failed;
this._errorMessage = message;
this.ForceReport(_progress);
}
public ProgressReporter CreateChild(int proportion, int expectedSteps = 100)
{
var child = new ProgressReporter(proportion, expectedSteps);
child.Parent = this;
this.Child = child;
return child;
}
}
public class ProgressTrackerService : BackgroundService
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly ConcurrentDictionary<string, TaskProgressInfo> _taskMap = new();
private readonly IHubContext<ProgressHub, IProgressReceiver> _hubContext;
private class TaskProgressInfo
{
public ProgressReporter Reporter { get; set; }
public string? ConnectionId { get; set; }
public required CancellationToken CancellationToken { get; set; }
public required CancellationTokenSource CancellationTokenSource { get; set; }
public required DateTime UpdatedAt { get; set; }
}
public ProgressTrackerService(IHubContext<ProgressHub, IProgressReceiver> hubContext)
{
_hubContext = hubContext;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
var now = DateTime.UtcNow;
foreach (var kvp in _taskMap)
{
var info = kvp.Value;
// 超过 1 分钟且任务已完成/失败/取消
if ((now - info.UpdatedAt).TotalMinutes > 1 &&
(info.Reporter.Status == ProgressStatus.Completed ||
info.Reporter.Status == ProgressStatus.Failed ||
info.Reporter.Status == ProgressStatus.Canceled))
{
_taskMap.TryRemove(kvp.Key, out _);
logger.Info($"Cleaned up task {kvp.Key}");
}
}
}
catch (Exception ex)
{
logger.Error(ex, "Error during ProgressTracker cleanup");
}
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
}
}
public (string, ProgressReporter) CreateTask(CancellationToken? cancellationToken = null)
{
CancellationTokenSource? cancellationTokenSource;
if (cancellationToken.HasValue)
{
cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken.Value);
}
else
{
cancellationTokenSource = new CancellationTokenSource();
}
var progressInfo = new TaskProgressInfo
{
ConnectionId = null,
UpdatedAt = DateTime.UtcNow,
CancellationToken = cancellationTokenSource.Token,
CancellationTokenSource = cancellationTokenSource,
};
var progress = new ProgressReporter(async value =>
{
cancellationTokenSource.Token.ThrowIfCancellationRequested();
// 通过 SignalR 推送进度
if (progressInfo.ConnectionId != null)
await _hubContext.Clients.Client(progressInfo.ConnectionId).OnReceiveProgress(progressInfo.Reporter);
});
progressInfo.Reporter = progress;
_taskMap.TryAdd(progressInfo.Reporter.TaskId, progressInfo);
return (progressInfo.Reporter.TaskId, progress);
}
public Optional<ProgressReporter> GetReporter(string taskId)
{
if (_taskMap.TryGetValue(taskId, out var info))
{
return info.Reporter;
}
return Optional<ProgressReporter>.None;
}
public Optional<ProgressStatus> GetProgressStatus(string taskId)
{
if (_taskMap.TryGetValue(taskId, out var info))
{
return info.Reporter.Status;
}
return Optional<ProgressStatus>.None;
}
public bool BindTask(string taskId, string connectionId)
{
if (_taskMap.TryGetValue(taskId, out var info) && info != null)
{
lock (info)
{
info.ConnectionId = connectionId;
}
return true;
}
return false;
}
public bool CancelTask(string taskId)
{
try
{
if (_taskMap.TryGetValue(taskId, out var info) && info != null)
{
lock (info)
{
info.CancellationTokenSource.Cancel();
info.Reporter.Cancel();
info.UpdatedAt = DateTime.UtcNow;
}
return true;
}
return false;
}
catch (Exception ex)
{
logger.Error(ex, $"Failed to cancel task {taskId}");
return false;
}
}
}

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

@@ -8,11 +8,11 @@ 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>
/// 发送字符串
@@ -184,37 +184,19 @@ 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);
}
/// <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)
{
var isSuccessful = true;
while (times-- >= 0)
return await Task.Run(async () =>
{
isSuccessful = SendStringLocalHost(port, stringArray);
if (!isSuccessful) break;
Thread.Sleep(sleepMilliSeconds);
}
return isSuccessful;
var ret = SendAddrPack(endPoint,
new WebProtocol.SendAddrPackage(BurstType.FixedBurst, 0, true, 0, 0xF0F0F0F0));
await Task.Delay(100);
return ret;
});
}
/// <summary>
@@ -229,11 +211,11 @@ public class UDPClientPool
public static async ValueTask<Result<RecvDataPackage>> ReadAddr(
IPEndPoint endPoint, int taskID, uint devAddr, int dataLength, int timeout = 1000)
{
if (dataLength <= 0)
return new(new ArgumentException("Data length must be greater than 0"));
if (dataLength <= 0) return new(new ArgumentException(
$"Data length must be greater than 0, instead of {dataLength}"));
if (dataLength > 255)
return new(new ArgumentException("Data length must be less than or equal to 255"));
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()
@@ -253,8 +235,7 @@ public class UDPClientPool
if (!MsgBus.IsRunning)
return new(new Exception("Message Bus not Working!"));
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);
else if (!retPack.Value.IsSuccessful)
return new(new Exception("Send address package failed"));
@@ -277,7 +258,7 @@ public class UDPClientPool
public static async ValueTask<Result<RecvDataPackage>> ReadAddrByte(
IPEndPoint endPoint, int taskID, uint devAddr, int timeout = 1000)
{
return await ReadAddr(endPoint, taskID, devAddr, 0, timeout);
return await ReadAddr(endPoint, taskID, devAddr, 1, timeout);
}
/// <summary>
@@ -423,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)
@@ -607,10 +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, ProgressReporter? progress = null)
UInt32 data, int timeout = 1000, string progressId = "")
{
var ret = false;
var opts = new SendAddrPackOptions()
@@ -621,27 +602,27 @@ public class UDPClientPool
Address = devAddr,
IsWrite = true,
};
progress?.Report(20);
_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!"));
progress?.Report(40);
_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!"));
progress?.Report(60);
_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);
progress?.Finish();
_progressTracker.AdvanceProgress(progressId, 10);
return udpWriteAck.Value.IsSuccessful;
}
@@ -654,10 +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, ProgressReporter? progress = null)
byte[] dataArray, int timeout = 1000, string progressId = "")
{
var ret = false;
var opts = new SendAddrPackOptions()
@@ -679,8 +661,6 @@ public class UDPClientPool
var writeTimes = hasRest ?
dataArray.Length / (max4BytesPerRead * (32 / 8)) + 1 :
dataArray.Length / (max4BytesPerRead * (32 / 8));
if (progress != null)
progress.ExpectedSteps = writeTimes;
for (var i = 0; i < writeTimes; i++)
{
// Sperate Data Array
@@ -704,16 +684,15 @@ 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;
progress?.Increase();
_progressTracker.AdvanceProgress(progressId, 1);
}
progress?.Finish();
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>

View File

@@ -299,7 +299,7 @@ export class VideoStreamClient {
return Promise.resolve<boolean>(null as any);
}
setVideoStreamEnable(enable: boolean | undefined, cancelToken?: CancelToken): Promise<any> {
setVideoStreamEnable(enable: boolean | undefined, cancelToken?: CancelToken): Promise<string> {
let url_ = this.baseUrl + "/api/VideoStream/SetVideoStreamEnable?";
if (enable === null)
throw new Error("The parameter 'enable' cannot be null.");
@@ -327,7 +327,7 @@ export class VideoStreamClient {
});
}
protected processSetVideoStreamEnable(response: AxiosResponse): Promise<any> {
protected processSetVideoStreamEnable(response: AxiosResponse): Promise<string> {
const status = response.status;
let _headers: any = {};
if (response.headers && typeof response.headers === "object") {
@@ -343,7 +343,7 @@ export class VideoStreamClient {
let resultData200 = _responseText;
result200 = resultData200 !== undefined ? resultData200 : <any>null;
return Promise.resolve<any>(result200);
return Promise.resolve<string>(result200);
} else if (status === 500) {
const _responseText = response.data;
@@ -357,7 +357,7 @@ export class VideoStreamClient {
const _responseText = response.data;
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
}
return Promise.resolve<any>(null as any);
return Promise.resolve<string>(null as any);
}
/**
@@ -1675,6 +1675,54 @@ export class DataClient {
}
return Promise.resolve<number>(null as any);
}
addEmptyBoard( cancelToken?: CancelToken): Promise<void> {
let url_ = this.baseUrl + "/api/Data/AddEmptyBoard";
url_ = url_.replace(/[?&]$/, "");
let options_: AxiosRequestConfig = {
method: "POST",
url: url_,
headers: {
},
cancelToken
};
return this.instance.request(options_).catch((_error: any) => {
if (isAxiosError(_error) && _error.response) {
return _error.response;
} else {
throw _error;
}
}).then((_response: AxiosResponse) => {
return this.processAddEmptyBoard(_response);
});
}
protected processAddEmptyBoard(response: AxiosResponse): Promise<void> {
const status = response.status;
let _headers: any = {};
if (response.headers && typeof response.headers === "object") {
for (const k in response.headers) {
if (response.headers.hasOwnProperty(k)) {
_headers[k] = response.headers[k];
}
}
}
if (status === 200) {
const _responseText = response.data;
return Promise.resolve<void>(null as any);
} else if (status === 500) {
const _responseText = response.data;
return throwException("A server side error occurred.", status, _responseText, _headers);
} else if (status !== 200 && status !== 204) {
const _responseText = response.data;
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
}
return Promise.resolve<void>(null as any);
}
}
export class DDSClient {
@@ -2840,7 +2888,7 @@ export class ExamClient {
* @param file (optional) 提交的文件
* @return 提交结果
*/
submitCommit(examId: string, file: FileParameter | undefined, cancelToken?: CancelToken): Promise<Commit> {
commit(examId: string, file: FileParameter | undefined, cancelToken?: CancelToken): Promise<ResourceInfo> {
let url_ = this.baseUrl + "/api/Exam/commit/{examId}";
if (examId === undefined || examId === null)
throw new Error("The parameter 'examId' must be defined.");
@@ -2870,11 +2918,11 @@ export class ExamClient {
throw _error;
}
}).then((_response: AxiosResponse) => {
return this.processSubmitCommit(_response);
return this.processCommit(_response);
});
}
protected processSubmitCommit(response: AxiosResponse): Promise<Commit> {
protected processCommit(response: AxiosResponse): Promise<ResourceInfo> {
const status = response.status;
let _headers: any = {};
if (response.headers && typeof response.headers === "object") {
@@ -2888,8 +2936,8 @@ export class ExamClient {
const _responseText = response.data;
let result201: any = null;
let resultData201 = _responseText;
result201 = Commit.fromJS(resultData201);
return Promise.resolve<Commit>(result201);
result201 = ResourceInfo.fromJS(resultData201);
return Promise.resolve<ResourceInfo>(result201);
} else if (status === 400) {
const _responseText = response.data;
@@ -2920,7 +2968,7 @@ export class ExamClient {
const _responseText = response.data;
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
}
return Promise.resolve<Commit>(null as any);
return Promise.resolve<ResourceInfo>(null as any);
}
/**
@@ -2928,7 +2976,7 @@ export class ExamClient {
* @param examId 实验ID
* @return 提交记录列表
*/
getCommitsByExamId(examId: string, cancelToken?: CancelToken): Promise<Commit[]> {
getCommitsByExamId(examId: string, cancelToken?: CancelToken): Promise<ResourceInfo[]> {
let url_ = this.baseUrl + "/api/Exam/commits/{examId}";
if (examId === undefined || examId === null)
throw new Error("The parameter 'examId' must be defined.");
@@ -2955,7 +3003,7 @@ export class ExamClient {
});
}
protected processGetCommitsByExamId(response: AxiosResponse): Promise<Commit[]> {
protected processGetCommitsByExamId(response: AxiosResponse): Promise<ResourceInfo[]> {
const status = response.status;
let _headers: any = {};
if (response.headers && typeof response.headers === "object") {
@@ -2972,12 +3020,12 @@ export class ExamClient {
if (Array.isArray(resultData200)) {
result200 = [] as any;
for (let item of resultData200)
result200!.push(Commit.fromJS(item));
result200!.push(ResourceInfo.fromJS(item));
}
else {
result200 = <any>null;
}
return Promise.resolve<Commit[]>(result200);
return Promise.resolve<ResourceInfo[]>(result200);
} else if (status === 400) {
const _responseText = response.data;
@@ -3008,7 +3056,7 @@ export class ExamClient {
const _responseText = response.data;
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
}
return Promise.resolve<Commit[]>(null as any);
return Promise.resolve<ResourceInfo[]>(null as any);
}
/**
@@ -5521,7 +5569,7 @@ export class OscilloscopeApiClient {
* @param config 示波器配置
* @return 操作结果
*/
initialize(config: OscilloscopeFullConfig, cancelToken?: CancelToken): Promise<boolean> {
initialize(config: OscilloscopeConfig, cancelToken?: CancelToken): Promise<boolean> {
let url_ = this.baseUrl + "/api/OscilloscopeApi/Initialize";
url_ = url_.replace(/[?&]$/, "");
@@ -6562,7 +6610,7 @@ export class ResourceClient {
* @param file (optional) 资源文件
* @return 添加结果
*/
addResource(resourceType: string | undefined, resourcePurpose: string | undefined, examID: string | null | undefined, file: FileParameter | undefined, cancelToken?: CancelToken): Promise<ResourceInfo> {
addResource(resourceType: string | undefined, resourcePurpose: ResourcePurpose | undefined, examID: string | null | undefined, file: FileParameter | undefined, cancelToken?: CancelToken): Promise<ResourceInfo> {
let url_ = this.baseUrl + "/api/Resource";
url_ = url_.replace(/[?&]$/, "");
@@ -6659,7 +6707,7 @@ export class ResourceClient {
* @param resourcePurpose (optional) 资源用途(可选)
* @return 资源列表
*/
getResourceList(examId: string | null | undefined, resourceType: string | null | undefined, resourcePurpose: string | null | undefined, cancelToken?: CancelToken): Promise<ResourceInfo[]> {
getResourceList(examId: string | null | undefined, resourceType: string | null | undefined, resourcePurpose: ResourcePurpose | null | undefined, cancelToken?: CancelToken): Promise<ResourceInfo[]> {
let url_ = this.baseUrl + "/api/Resource?";
if (examId !== undefined && examId !== null)
url_ += "examId=" + encodeURIComponent("" + examId) + "&";
@@ -6888,6 +6936,255 @@ export class ResourceClient {
}
}
export class SwitchClient {
protected instance: AxiosInstance;
protected baseUrl: string;
protected jsonParseReviver: ((key: string, value: any) => any) | undefined = undefined;
constructor(baseUrl?: string, instance?: AxiosInstance) {
this.instance = instance || axios.create();
this.baseUrl = baseUrl ?? "http://127.0.0.1:5000";
}
/**
* 启用或禁用 Switch 外设
* @param enable (optional) 是否启用
* @return 操作结果
*/
setEnable(enable: boolean | undefined, cancelToken?: CancelToken): Promise<boolean> {
let url_ = this.baseUrl + "/api/Switch/enable?";
if (enable === null)
throw new Error("The parameter 'enable' cannot be null.");
else if (enable !== undefined)
url_ += "enable=" + encodeURIComponent("" + enable) + "&";
url_ = url_.replace(/[?&]$/, "");
let options_: AxiosRequestConfig = {
method: "POST",
url: url_,
headers: {
"Accept": "application/json"
},
cancelToken
};
return this.instance.request(options_).catch((_error: any) => {
if (isAxiosError(_error) && _error.response) {
return _error.response;
} else {
throw _error;
}
}).then((_response: AxiosResponse) => {
return this.processSetEnable(_response);
});
}
protected processSetEnable(response: AxiosResponse): Promise<boolean> {
const status = response.status;
let _headers: any = {};
if (response.headers && typeof response.headers === "object") {
for (const k in response.headers) {
if (response.headers.hasOwnProperty(k)) {
_headers[k] = response.headers[k];
}
}
}
if (status === 200) {
const _responseText = response.data;
let result200: any = null;
let resultData200 = _responseText;
result200 = resultData200 !== undefined ? resultData200 : <any>null;
return Promise.resolve<boolean>(result200);
} else if (status === 500) {
const _responseText = response.data;
let result500: any = null;
let resultData500 = _responseText;
result500 = Exception.fromJS(resultData500);
return throwException("A server side error occurred.", status, _responseText, _headers, result500);
} else if (status === 401) {
const _responseText = response.data;
let result401: any = null;
let resultData401 = _responseText;
result401 = ProblemDetails.fromJS(resultData401);
return throwException("A server side error occurred.", status, _responseText, _headers, result401);
} else if (status !== 200 && status !== 204) {
const _responseText = response.data;
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
}
return Promise.resolve<boolean>(null as any);
}
/**
* 控制指定编号的 Switch 开关
* @param num (optional) 开关编号
* @param onOff (optional) 开/关
* @return 操作结果
*/
setSwitchOnOff(num: number | undefined, onOff: boolean | undefined, cancelToken?: CancelToken): Promise<boolean> {
let url_ = this.baseUrl + "/api/Switch/switch?";
if (num === null)
throw new Error("The parameter 'num' cannot be null.");
else if (num !== undefined)
url_ += "num=" + encodeURIComponent("" + num) + "&";
if (onOff === null)
throw new Error("The parameter 'onOff' cannot be null.");
else if (onOff !== undefined)
url_ += "onOff=" + encodeURIComponent("" + onOff) + "&";
url_ = url_.replace(/[?&]$/, "");
let options_: AxiosRequestConfig = {
method: "POST",
url: url_,
headers: {
"Accept": "application/json"
},
cancelToken
};
return this.instance.request(options_).catch((_error: any) => {
if (isAxiosError(_error) && _error.response) {
return _error.response;
} else {
throw _error;
}
}).then((_response: AxiosResponse) => {
return this.processSetSwitchOnOff(_response);
});
}
protected processSetSwitchOnOff(response: AxiosResponse): Promise<boolean> {
const status = response.status;
let _headers: any = {};
if (response.headers && typeof response.headers === "object") {
for (const k in response.headers) {
if (response.headers.hasOwnProperty(k)) {
_headers[k] = response.headers[k];
}
}
}
if (status === 200) {
const _responseText = response.data;
let result200: any = null;
let resultData200 = _responseText;
result200 = resultData200 !== undefined ? resultData200 : <any>null;
return Promise.resolve<boolean>(result200);
} else if (status === 400) {
const _responseText = response.data;
let result400: any = null;
let resultData400 = _responseText;
result400 = ArgumentException.fromJS(resultData400);
return throwException("A server side error occurred.", status, _responseText, _headers, result400);
} else if (status === 500) {
const _responseText = response.data;
let result500: any = null;
let resultData500 = _responseText;
result500 = Exception.fromJS(resultData500);
return throwException("A server side error occurred.", status, _responseText, _headers, result500);
} else if (status === 401) {
const _responseText = response.data;
let result401: any = null;
let resultData401 = _responseText;
result401 = ProblemDetails.fromJS(resultData401);
return throwException("A server side error occurred.", status, _responseText, _headers, result401);
} else if (status !== 200 && status !== 204) {
const _responseText = response.data;
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
}
return Promise.resolve<boolean>(null as any);
}
/**
* 控制 Switch 开关
* @param keyStatus 开关状态
* @return 操作结果
*/
setMultiSwitchsOnOff(keyStatus: boolean[], cancelToken?: CancelToken): Promise<boolean> {
let url_ = this.baseUrl + "/api/Switch/MultiSwitch";
url_ = url_.replace(/[?&]$/, "");
const content_ = JSON.stringify(keyStatus);
let options_: AxiosRequestConfig = {
data: content_,
method: "POST",
url: url_,
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
},
cancelToken
};
return this.instance.request(options_).catch((_error: any) => {
if (isAxiosError(_error) && _error.response) {
return _error.response;
} else {
throw _error;
}
}).then((_response: AxiosResponse) => {
return this.processSetMultiSwitchsOnOff(_response);
});
}
protected processSetMultiSwitchsOnOff(response: AxiosResponse): Promise<boolean> {
const status = response.status;
let _headers: any = {};
if (response.headers && typeof response.headers === "object") {
for (const k in response.headers) {
if (response.headers.hasOwnProperty(k)) {
_headers[k] = response.headers[k];
}
}
}
if (status === 200) {
const _responseText = response.data;
let result200: any = null;
let resultData200 = _responseText;
result200 = resultData200 !== undefined ? resultData200 : <any>null;
return Promise.resolve<boolean>(result200);
} else if (status === 400) {
const _responseText = response.data;
let result400: any = null;
let resultData400 = _responseText;
result400 = ArgumentException.fromJS(resultData400);
return throwException("A server side error occurred.", status, _responseText, _headers, result400);
} else if (status === 500) {
const _responseText = response.data;
let result500: any = null;
let resultData500 = _responseText;
result500 = Exception.fromJS(resultData500);
return throwException("A server side error occurred.", status, _responseText, _headers, result500);
} else if (status === 401) {
const _responseText = response.data;
let result401: any = null;
let resultData401 = _responseText;
result401 = ProblemDetails.fromJS(resultData401);
return throwException("A server side error occurred.", status, _responseText, _headers, result401);
} else if (status !== 200 && status !== 204) {
const _responseText = response.data;
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
}
return Promise.resolve<boolean>(null as any);
}
}
export class TutorialClient {
protected instance: AxiosInstance;
protected baseUrl: string;
@@ -8330,18 +8627,24 @@ export interface IExamDto {
isVisibleToUsers: boolean;
}
export class Commit implements ICommit {
/** 资源的唯一标识符 */
/** 资源信息类 */
export class ResourceInfo implements IResourceInfo {
/** 资源ID */
id!: string;
/** 上传资源的用户ID */
userID!: string;
/** 所属实验ID */
/** 资源名称 */
name!: string;
/** 资源类型 */
type!: string;
/** 资源用途template/user */
purpose!: ResourcePurpose;
/** 上传时间 */
uploadTime!: Date;
/** 所属实验ID可选 */
examID?: string | undefined;
type!: CommitType;
resourceID!: string;
createdAt!: Date;
/** MIME类型 */
mimeType?: string | undefined;
constructor(data?: ICommit) {
constructor(data?: IResourceInfo) {
if (data) {
for (var property in data) {
if (data.hasOwnProperty(property))
@@ -8353,17 +8656,18 @@ export class Commit implements ICommit {
init(_data?: any) {
if (_data) {
this.id = _data["id"];
this.userID = _data["userID"];
this.examID = _data["examID"];
this.name = _data["name"];
this.type = _data["type"];
this.resourceID = _data["resourceID"];
this.createdAt = _data["createdAt"] ? new Date(_data["createdAt"].toString()) : <any>undefined;
this.purpose = _data["purpose"];
this.uploadTime = _data["uploadTime"] ? new Date(_data["uploadTime"].toString()) : <any>undefined;
this.examID = _data["examID"];
this.mimeType = _data["mimeType"];
}
}
static fromJS(data: any): Commit {
static fromJS(data: any): ResourceInfo {
data = typeof data === 'object' ? data : {};
let result = new Commit();
let result = new ResourceInfo();
result.init(data);
return result;
}
@@ -8371,31 +8675,38 @@ export class Commit implements ICommit {
toJSON(data?: any) {
data = typeof data === 'object' ? data : {};
data["id"] = this.id;
data["userID"] = this.userID;
data["examID"] = this.examID;
data["name"] = this.name;
data["type"] = this.type;
data["resourceID"] = this.resourceID;
data["createdAt"] = this.createdAt ? this.createdAt.toISOString() : <any>undefined;
data["purpose"] = this.purpose;
data["uploadTime"] = this.uploadTime ? this.uploadTime.toISOString() : <any>undefined;
data["examID"] = this.examID;
data["mimeType"] = this.mimeType;
return data;
}
}
export interface ICommit {
/** 资源的唯一标识符 */
/** 资源信息类 */
export interface IResourceInfo {
/** 资源ID */
id: string;
/** 上传资源的用户ID */
userID: string;
/** 所属实验ID */
/** 资源名称 */
name: string;
/** 资源类型 */
type: string;
/** 资源用途template/user */
purpose: ResourcePurpose;
/** 上传时间 */
uploadTime: Date;
/** 所属实验ID可选 */
examID?: string | undefined;
type: CommitType;
resourceID: string;
createdAt: Date;
/** MIME类型 */
mimeType?: string | undefined;
}
export enum CommitType {
Homework = 0,
Project = 1,
Markdown = 2,
export enum ResourcePurpose {
Template = 0,
User = 1,
Homework = 2,
}
export class HdmiVideoStreamEndpoint implements IHdmiVideoStreamEndpoint {
@@ -8779,22 +9090,14 @@ export interface INetworkInterfaceDto {
macAddress: string;
}
/** 示波器完整配置 */
export class OscilloscopeFullConfig implements IOscilloscopeFullConfig {
/** 是否启动捕获 */
export class OscilloscopeConfig implements IOscilloscopeConfig {
captureEnabled!: boolean;
/** 触发电平0-255 */
triggerLevel!: number;
/** 触发边沿true为上升沿false为下降沿 */
triggerRisingEdge!: boolean;
/** 水平偏移量0-1023 */
horizontalShift!: number;
/** 抽样率0-1023 */
decimationRate!: number;
/** 是否自动刷新RAM */
autoRefreshRAM!: boolean;
constructor(data?: IOscilloscopeFullConfig) {
constructor(data?: IOscilloscopeConfig) {
if (data) {
for (var property in data) {
if (data.hasOwnProperty(property))
@@ -8810,13 +9113,12 @@ export class OscilloscopeFullConfig implements IOscilloscopeFullConfig {
this.triggerRisingEdge = _data["triggerRisingEdge"];
this.horizontalShift = _data["horizontalShift"];
this.decimationRate = _data["decimationRate"];
this.autoRefreshRAM = _data["autoRefreshRAM"];
}
}
static fromJS(data: any): OscilloscopeFullConfig {
static fromJS(data: any): OscilloscopeConfig {
data = typeof data === 'object' ? data : {};
let result = new OscilloscopeFullConfig();
let result = new OscilloscopeConfig();
result.init(data);
return result;
}
@@ -8828,38 +9130,23 @@ export class OscilloscopeFullConfig implements IOscilloscopeFullConfig {
data["triggerRisingEdge"] = this.triggerRisingEdge;
data["horizontalShift"] = this.horizontalShift;
data["decimationRate"] = this.decimationRate;
data["autoRefreshRAM"] = this.autoRefreshRAM;
return data;
}
}
/** 示波器完整配置 */
export interface IOscilloscopeFullConfig {
/** 是否启动捕获 */
export interface IOscilloscopeConfig {
captureEnabled: boolean;
/** 触发电平0-255 */
triggerLevel: number;
/** 触发边沿true为上升沿false为下降沿 */
triggerRisingEdge: boolean;
/** 水平偏移量0-1023 */
horizontalShift: number;
/** 抽样率0-1023 */
decimationRate: number;
/** 是否自动刷新RAM */
autoRefreshRAM: boolean;
}
/** 示波器状态和数据 */
export class OscilloscopeDataResponse implements IOscilloscopeDataResponse {
/** AD采样频率 */
adFrequency!: number;
/** AD采样幅度 */
adVpp!: number;
/** AD采样最大值 */
adMax!: number;
/** AD采样最小值 */
adMin!: number;
/** 波形数据Base64编码 */
waveformData!: string;
constructor(data?: IOscilloscopeDataResponse) {
@@ -8899,96 +9186,14 @@ export class OscilloscopeDataResponse implements IOscilloscopeDataResponse {
}
}
/** 示波器状态和数据 */
export interface IOscilloscopeDataResponse {
/** AD采样频率 */
adFrequency: number;
/** AD采样幅度 */
adVpp: number;
/** AD采样最大值 */
adMax: number;
/** AD采样最小值 */
adMin: number;
/** 波形数据Base64编码 */
waveformData: string;
}
/** 资源信息类 */
export class ResourceInfo implements IResourceInfo {
/** 资源ID */
id!: string;
/** 资源名称 */
name!: string;
/** 资源类型 */
type!: string;
/** 资源用途template/user */
purpose!: string;
/** 上传时间 */
uploadTime!: Date;
/** 所属实验ID可选 */
examID?: string | undefined;
/** MIME类型 */
mimeType?: string | undefined;
constructor(data?: IResourceInfo) {
if (data) {
for (var property in data) {
if (data.hasOwnProperty(property))
(<any>this)[property] = (<any>data)[property];
}
}
}
init(_data?: any) {
if (_data) {
this.id = _data["id"];
this.name = _data["name"];
this.type = _data["type"];
this.purpose = _data["purpose"];
this.uploadTime = _data["uploadTime"] ? new Date(_data["uploadTime"].toString()) : <any>undefined;
this.examID = _data["examID"];
this.mimeType = _data["mimeType"];
}
}
static fromJS(data: any): ResourceInfo {
data = typeof data === 'object' ? data : {};
let result = new ResourceInfo();
result.init(data);
return result;
}
toJSON(data?: any) {
data = typeof data === 'object' ? data : {};
data["id"] = this.id;
data["name"] = this.name;
data["type"] = this.type;
data["purpose"] = this.purpose;
data["uploadTime"] = this.uploadTime ? this.uploadTime.toISOString() : <any>undefined;
data["examID"] = this.examID;
data["mimeType"] = this.mimeType;
return data;
}
}
/** 资源信息类 */
export interface IResourceInfo {
/** 资源ID */
id: string;
/** 资源名称 */
name: string;
/** 资源类型 */
type: string;
/** 资源用途template/user */
purpose: string;
/** 上传时间 */
uploadTime: Date;
/** 所属实验ID可选 */
examID?: string | undefined;
/** MIME类型 */
mimeType?: string | undefined;
}
/** Package options which to send address to read or write */
export class SendAddrPackOptions implements ISendAddrPackOptions {
/** 突发类型 */

View File

@@ -86,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>

View File

@@ -1,3 +1,6 @@
import { ResourceClient, ResourcePurpose } from "@/APIClient";
import { AuthManager } from "@/utils/AuthManager";
// 定义 diagram.json 的类型结构
export interface DiagramData {
version: number;
@@ -26,40 +29,43 @@ export interface DiagramPart {
// 连接类型定义 - 使用元组类型表示四元素数组
export type ConnectionArray = [string, string, number, string[]];
import { AuthManager } from '@/utils/AuthManager';
// 解析连接字符串为组件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,
};
}
@@ -76,7 +82,7 @@ export interface WireItem {
endPinId?: string;
strokeWidth: number;
color: string;
routingMode: 'orthogonal' | 'path';
routingMode: "orthogonal" | "path";
constraint?: string;
pathCommands?: string[];
showLabel: boolean;
@@ -88,58 +94,64 @@ export async function loadDiagramData(examId?: string): Promise<DiagramData> {
// 如果提供了examId优先从API加载实验的diagram
if (examId) {
try {
const resourceClient = AuthManager.createAuthenticatedResourceClient();
const resourceClient = AuthManager.createClient(ResourceClient);
// 获取diagram类型的资源列表
const resources = await resourceClient.getResourceList(examId, 'canvas', 'template');
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);
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);
console.log("成功从API加载实验diagram:", examId);
return data;
} else {
console.warn('API返回的diagram数据格式无效:', validation.errors);
console.warn("API返回的diagram数据格式无效:", validation.errors);
}
}
} else {
console.log('未找到实验diagram资源使用默认加载方式');
console.log("未找到实验diagram资源使用默认加载方式");
}
} catch (error) {
console.warn('从API加载实验diagram失败使用默认加载方式:', error);
console.warn("从API加载实验diagram失败使用默认加载方式:", error);
}
}
// 如果没有examId或API加载失败尝试从静态文件加载不再使用本地存储
// 从静态文件加载(作为备选方案)
const response = await fetch('/src/components/diagram.json');
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();
// 验证静态文件数据
const validation = validateDiagramData(data);
if (validation.isValid) {
return data;
} else {
console.warn('静态diagram文件数据格式无效:', validation.errors);
throw new Error('所有diagram数据源都无效');
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();
}
@@ -149,33 +161,31 @@ export async function loadDiagramData(examId?: string): 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 {
// 本地存储功能已禁用 - 不再保存到localStorage
console.debug('saveDiagramData called but localStorage saving is disabled');
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,
),
};
}
@@ -184,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,
),
};
}
@@ -210,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) => {
@@ -283,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

@@ -31,8 +31,16 @@ export type Channel = {
// 全局模式选项
const globalModes = [
{value: GlobalCaptureMode.AND,label: "AND",description: "所有条件都满足时触发",},
{value: GlobalCaptureMode.OR,label: "OR",description: "任一条件满足时触发",},
{
value: GlobalCaptureMode.AND,
label: "AND",
description: "所有条件都满足时触发",
},
{
value: GlobalCaptureMode.OR,
label: "OR",
description: "任一条件满足时触发",
},
{ value: GlobalCaptureMode.NAND, label: "NAND", description: "AND的非" },
{ value: GlobalCaptureMode.NOR, label: "NOR", description: "OR的非" },
];
@@ -70,21 +78,53 @@ const channelDivOptions = [
];
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" },
{
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 CAPTURE_LENGTH_MIN = 1024; // 最小捕获深度 1024
const CAPTURE_LENGTH_MAX = 0x10000000 - 0x01000000; // 最大捕获深度
// 预捕获深度限制常量
// 预捕获深度限制常量
const PRE_CAPTURE_LENGTH_MIN = 2; // 最小预捕获深度 2
// 默认颜色数组
@@ -170,40 +210,64 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
// 转换通道数字到枚举值
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 } => {
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}` };
return {
valid: false,
message: `捕获深度不能小于 ${CAPTURE_LENGTH_MIN}`,
};
}
if (value > CAPTURE_LENGTH_MAX) {
return { valid: false, message: `捕获深度不能大于 ${CAPTURE_LENGTH_MAX.toLocaleString()}` };
return {
valid: false,
message: `捕获深度不能大于 ${CAPTURE_LENGTH_MAX.toLocaleString()}`,
};
}
return { valid: true };
};
// 验证预捕获深度
const validatePreCaptureLength = (value: number, currentCaptureLength: number): { valid: boolean; message?: string } => {
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}` };
return {
valid: false,
message: `预捕获深度不能小于 ${PRE_CAPTURE_LENGTH_MIN}`,
};
}
if (value >= currentCaptureLength) {
return { valid: false, message: `预捕获深度不能大于等于捕获深度 (${currentCaptureLength})` };
return {
valid: false,
message: `预捕获深度不能大于等于捕获深度 (${currentCaptureLength})`,
};
}
return { valid: true };
};
@@ -215,13 +279,13 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
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;
};
@@ -233,7 +297,7 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
alert?.error(validation.message!, 3000);
return false;
}
preCaptureLength.value = value;
return true;
};
@@ -241,12 +305,12 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
// 设置通道组
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;
@@ -257,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);
};
@@ -294,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);
@@ -308,7 +374,7 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
// 根据当前通道数量解析数据
const channelCount = currentChannelDiv.value;
const timeStepNs = currentSamplePeriodNs.value;
let sampleCount: number;
let x: number[];
let y: number[][];
@@ -316,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];
@@ -340,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++) {
@@ -360,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++) {
@@ -400,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];
@@ -424,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;
@@ -456,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;
@@ -525,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) {
@@ -632,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);
@@ -661,7 +709,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(true, true);
@@ -677,7 +725,7 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
`强制捕获失败: ${error instanceof Error ? error.message : "未知错误"}`,
3000,
);
} finally{
} finally {
release();
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -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

@@ -81,7 +81,12 @@
import { ref, onMounted, onUnmounted } from "vue";
import { useRouter } from "vue-router";
import { AuthManager } from "@/utils/AuthManager";
import type { ExamInfo } from "@/APIClient";
import {
ExamClient,
ResourceClient,
ResourcePurpose,
type ExamInfo,
} from "@/APIClient";
// 接口定义
interface Tutorial {
@@ -121,7 +126,7 @@ onMounted(async () => {
console.log("正在从数据库加载实验数据...");
// 创建认证客户端
const client = AuthManager.createAuthenticatedExamClient();
const client = AuthManager.createClient(ExamClient);
// 获取实验列表
const examList: ExamInfo[] = await client.getExamList();
@@ -142,11 +147,11 @@ onMounted(async () => {
try {
// 获取实验的封面资源(模板资源)
const resourceClient = AuthManager.createAuthenticatedResourceClient();
const resourceClient = AuthManager.createClient(ResourceClient);
const resourceList = await resourceClient.getResourceList(
exam.id,
"cover",
"template",
ResourcePurpose.Template,
);
if (resourceList && resourceList.length > 0) {
// 使用第一个封面资源

View File

@@ -16,22 +16,32 @@
<span class="text-sm">{{ bitstream.name }}</span>
<div class="flex gap-2">
<button
@click="downloadExampleBitstream(bitstream)"
@click="handleExampleBitstream('download', bitstream)"
class="btn btn-sm btn-secondary"
:disabled="isDownloading || isProgramming"
:disabled="currentTask !== 'none'"
>
<div v-if="isDownloading">
<div
v-if="
currentTask === 'downloading' &&
currentBitstreamId === bitstream.id
"
>
<span class="loading loading-spinner loading-xs"></span>
{{ downloadProgress }}%
下载中...
</div>
<div v-else>下载示例</div>
</button>
<button
@click="programExampleBitstream(bitstream)"
@click="handleExampleBitstream('program', bitstream)"
class="btn btn-sm btn-primary"
:disabled="isDownloading || isProgramming"
:disabled="currentTask !== 'none'"
>
<div v-if="isProgramming">
<div
v-if="
currentTask === 'programming' &&
currentBitstreamId === bitstream.id
"
>
<span class="loading loading-spinner loading-xs"></span>
烧录中...
</div>
@@ -63,14 +73,18 @@
<!-- Upload Button -->
<div class="card-actions w-full">
<button
@click="handleClick"
@click="handleUploadAndDownload"
class="btn btn-primary grow"
:disabled="isUploading || isProgramming"
:disabled="currentTask !== 'none'"
>
<div v-if="isUploading">
<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>
@@ -78,27 +92,19 @@
</template>
<script lang="ts" setup>
import { computed, ref, useTemplateRef, onMounted } from "vue";
import { AuthManager } from "@/utils/AuthManager"; // Adjust the path based on your project structure
import { ref, useTemplateRef, onMounted } from "vue";
import { AuthManager } from "@/utils/AuthManager";
import { useDialogStore } from "@/stores/dialog";
import { isNull, isUndefined } from "lodash";
import { useEquipments } from "@/stores/equipments";
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 } from "@/utils/signalR/server.Hubs";
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; // 新增examId属性
examId?: string;
}
const props = withDefaults(defineProps<Props>(), {
@@ -111,203 +117,166 @@ const emits = defineEmits<{
}>();
const alert = useRequiredInjection(useAlertStore);
const progressTracker = useProgressStore();
const dialog = useDialogStore();
const eqps = useEquipments();
const isUploading = ref(false);
const isDownloading = ref(false);
const isProgramming = ref(false);
const availableBitstreams = ref<{ id: number; name: string }[]>([]);
// Progress
const downloadTaskId = ref("");
const downloadProgress = ref(0);
const progressHubConnection = ref<HubConnection>();
const progressHubProxy = ref<IProgressHub>();
const progressHubReceiver: IProgressReceiver = {
onReceiveProgress: async (msg) => {
if (msg.taskId == downloadTaskId.value) {
if (msg.status == ProgressStatus.InProgress) {
downloadProgress.value = msg.progressPercent;
} else if (msg.status == ProgressStatus.Failed) {
dialog.error(msg.errorMessage);
} else if (msg.status == ProgressStatus.Completed) {
alert.info("比特流下载成功");
}
}
},
};
onMounted(async () => {
progressHubConnection.value =
AuthManager.createAuthenticatedProgressHubConnection();
progressHubProxy.value = getHubProxyFactory("IProgressHub").createHubProxy(
progressHubConnection.value,
);
getReceiverRegister("IProgressReceiver").register(
progressHubConnection.value,
progressHubReceiver,
);
});
const availableBitstreams = ref<{ id: string; name: string }[]>([]);
const fileInput = useTemplateRef("fileInput");
const bitstream = defineModel("bitstreamFile", {
type: File,
default: undefined,
});
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 (!isUndefined(bitstream.value) && !isNull(fileInput.value)) {
if (bitstream.value && fileInput.value) {
let fileList = new DataTransfer();
fileList.items.add(bitstream.value);
fileInput.value.files = fileList.files;
}
await loadAvailableBitstreams();
});
// 加载可用的比特流文件列表
async function loadAvailableBitstreams() {
console.log("加载可用比特流文件examId:", props.examId);
if (!props.examId) {
availableBitstreams.value = [];
return;
}
try {
const resourceClient = AuthManager.createAuthenticatedResourceClient();
// 使用新的ResourceClient API获取比特流模板资源列表
const resources = await resourceClient.getResourceList(
props.examId,
"bitstream",
"template",
);
availableBitstreams.value =
resources.map((r) => ({ id: r.id, name: r.name })) || [];
} catch (error) {
console.error("加载比特流列表失败:", error);
availableBitstreams.value = [];
}
}
// 下载示例比特流
async function downloadExampleBitstream(bitstream: {
id: number;
name: string;
}) {
if (isDownloading.value) return;
isDownloading.value = true;
try {
const resourceClient = AuthManager.createAuthenticatedResourceClient();
// 使用新的ResourceClient API获取资源文件
const response = await resourceClient.getResourceById(bitstream.id);
if (response && response.data) {
// 创建下载链接
const url = URL.createObjectURL(response.data);
const link = document.createElement("a");
link.href = url;
link.download = response.fileName || bitstream.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
dialog.info("示例比特流下载成功");
} else {
dialog.error("下载失败:响应数据为空");
}
} catch (error) {
console.error("下载示例比特流失败:", error);
dialog.error("下载示例比特流失败");
} finally {
isDownloading.value = false;
}
}
// 直接烧录示例比特流
async function programExampleBitstream(bitstream: {
id: number;
name: string;
}) {
if (isProgramming.value) return;
isProgramming.value = true;
try {
const downloadTaskId = await eqps.jtagDownloadBitstream(bitstream.id);
} catch (error) {
console.error("烧录示例比特流失败:", error);
dialog.error("烧录示例比特流失败");
} finally {
isProgramming.value = false;
}
}
function handleFileChange(event: Event): void {
const target = event.target as HTMLInputElement;
const file = target.files?.[0]; // 获取选中的第一个文件
if (!file) {
return;
}
bitstream.value = file;
const file = target.files?.[0];
bitstream.value = file || undefined;
}
function checkFile(file: File): boolean {
const maxBytes = props.maxMemory! * 1024 * 1024; // 将最大容量从 MB 转换为字节
if (file.size > maxBytes) {
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 handleClick(event: Event): Promise<void> {
console.log("上传按钮被点击");
if (isNull(bitstream.value) || isUndefined(bitstream.value)) {
dialog.error(`未选择文件`);
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;
}
if (!checkFile(bitstream.value)) return;
isUploading.value = true;
let uploadedBitstreamId: number | null = null;
try {
console.log("开始上传比特流文件:", bitstream.value.name);
const bitstreamId = await eqps.jtagUploadBitstream(
bitstream.value,
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 || "",
);
console.log("上传结果ID:", bitstreamId);
if (bitstreamId === null || bitstreamId === undefined) {
isUploading.value = false;
return;
}
uploadedBitstreamId = bitstreamId;
} catch (e) {
if (!uploadedBitstreamId) throw new Error("上传失败");
emits("finishedUpload", bitstream.value!);
} catch {
dialog.error("上传失败");
console.error(e);
currentTask.value = "none";
return;
}
isUploading.value = false;
// Download
try {
console.log("开始下载比特流ID:", uploadedBitstreamId);
if (uploadedBitstreamId === null || uploadedBitstreamId === undefined) {
dialog.error("uploadedBitstreamId is null or undefined");
} else {
isDownloading.value = true;
downloadTaskId.value =
await eqps.jtagDownloadBitstream(uploadedBitstreamId);
}
} catch (e) {
dialog.error("下载失败");
console.error(e);
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>

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

@@ -66,7 +66,7 @@
</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"
@@ -76,6 +76,15 @@
/>
<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>
@@ -146,6 +155,15 @@ 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.jtagBoundaryScanSetOnOff(!eqps.enableJtagBoundaryScan);
}

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,11 +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/Exam/Index.vue";
import MarkdownEditor from "@/components/MarkdownEditor.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),
@@ -14,8 +14,8 @@ 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,4 +1,4 @@
import { ref, reactive, watchPostEffect, onMounted, onUnmounted } from "vue";
import { ref, reactive, shallowRef, onMounted, onUnmounted } from "vue";
import { defineStore } from "pinia";
import { useLocalStorage } from "@vueuse/core";
import { isString, toNumber, isUndefined, type Dictionary } from "lodash";
@@ -7,15 +7,28 @@ import { isNumber } from "mathjs";
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, HubConnectionBuilder } from "@microsoft/signalr";
import { HubConnection, HubConnectionState } from "@microsoft/signalr";
import {
getHubProxyFactory,
getReceiverRegister,
} from "@/utils/signalR/TypedSignalR.Client";
import type { ResourceInfo } from "@/APIClient";
import type { IJtagHub } from "@/utils/signalR/TypedSignalR.Client/server.Hubs";
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
@@ -26,6 +39,7 @@ 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 jtagUserBitstreams = ref<ResourceInfo[]>([]);
@@ -34,13 +48,14 @@ export const useEquipments = defineStore("equipments", () => {
1000,
new Error("JtagClient Mutex Timeout!"),
);
// jtag Hub
const jtagHubConnection = ref<HubConnection>();
const jtagHubProxy = ref<IJtagHub>();
onMounted(async () => {
// 每次挂载都重新创建连接
jtagHubConnection.value =
AuthManager.createAuthenticatedJtagHubConnection();
jtagHubConnection.value = AuthManager.createHubConnection("JtagHub");
jtagHubProxy.value = getHubProxyFactory("IJtagHub").createHubProxy(
jtagHubConnection.value,
);
@@ -62,46 +77,6 @@ export const useEquipments = defineStore("equipments", () => {
}
});
// Matrix Key
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);
function setMatrixKey(
keyNum: number | string | undefined,
keyValue: boolean,
): boolean {
let _keyNum: number;
if (isString(keyNum)) {
_keyNum = toNumber(keyNum);
} else if (isNumber(keyNum)) {
_keyNum = keyNum;
} else {
return false;
}
if (z.number().nonnegative().max(16).safeParse(_keyNum).success) {
matrixKeyStates[_keyNum] = keyValue;
return true;
}
return false;
}
async function jtagBoundaryScanSetOnOff(enable: boolean) {
if (isUndefined(jtagHubProxy.value)) {
console.error("JtagHub Not Initialize...");
@@ -134,10 +109,10 @@ export const useEquipments = defineStore("equipments", () => {
// 自动开启电源
await powerSetOnOff(true);
const resourceClient = AuthManager.createAuthenticatedResourceClient();
const resourceClient = AuthManager.createClient(ResourceClient);
const resp = await resourceClient.addResource(
"bitstream",
"user",
ResourcePurpose.User,
examId || null,
toFileParameterOrUndefined(bitstream),
);
@@ -166,7 +141,7 @@ export const useEquipments = defineStore("equipments", () => {
// 自动开启电源
await powerSetOnOff(true);
const jtagClient = AuthManager.createAuthenticatedJtagClient();
const jtagClient = AuthManager.createClient(JtagClient);
const resp = await jtagClient.downloadBitstream(
boardAddr.value,
boardPort.value,
@@ -188,7 +163,7 @@ export const useEquipments = defineStore("equipments", () => {
// 自动开启电源
await powerSetOnOff(true);
const jtagClient = AuthManager.createAuthenticatedJtagClient();
const jtagClient = AuthManager.createClient(JtagClient);
const resp = await jtagClient.getDeviceIDCode(
boardAddr.value,
boardPort.value,
@@ -208,7 +183,7 @@ export const useEquipments = defineStore("equipments", () => {
// 自动开启电源
await powerSetOnOff(true);
const jtagClient = AuthManager.createAuthenticatedJtagClient();
const jtagClient = AuthManager.createClient(JtagClient);
const resp = await jtagClient.setSpeed(
boardAddr.value,
boardPort.value,
@@ -223,12 +198,38 @@ export const useEquipments = defineStore("equipments", () => {
}
}
// 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!"),
);
function setMatrixKey(
keyNum: number | string | undefined,
keyValue: boolean,
): boolean {
let _keyNum: number;
if (isString(keyNum)) {
_keyNum = toNumber(keyNum);
} else if (isNumber(keyNum)) {
_keyNum = keyNum;
} else {
return false;
}
if (z.number().nonnegative().max(16).safeParse(_keyNum).success) {
matrixKeyStates[_keyNum] = keyValue;
return true;
}
return false;
}
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,
@@ -246,9 +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,
@@ -256,8 +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,
@@ -274,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,
@@ -293,6 +298,85 @@ 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,
@@ -320,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,313 +1,110 @@
import {
DataClient,
VideoStreamClient,
BsdlParserClient,
DDSClient,
JtagClient,
MatrixKeyClient,
PowerClient,
RemoteUpdateClient,
TutorialClient,
UDPClient,
LogicAnalyzerClient,
NetConfigClient,
OscilloscopeApiClient,
DebuggerClient,
ExamClient,
ResourceClient,
HdmiVideoStreamClient,
} from "@/APIClient";
import router from "@/router";
import { DataClient } from "@/APIClient";
import { HubConnectionBuilder } from "@microsoft/signalr";
import axios, { type AxiosInstance } from "axios";
import { isNull } from "lodash";
// 支持的客户端类型联合类型
type SupportedClient =
| DataClient
| VideoStreamClient
| BsdlParserClient
| DDSClient
| JtagClient
| MatrixKeyClient
| PowerClient
| RemoteUpdateClient
| TutorialClient
| LogicAnalyzerClient
| UDPClient
| NetConfigClient
| OscilloscopeApiClient
| DebuggerClient
| ExamClient
| ResourceClient
| HdmiVideoStreamClient;
// 简单到让人想哭的认证管理器
export class AuthManager {
// 存储token到localStorage
public static setToken(token: string): void {
localStorage.setItem("authToken", token);
private static readonly TOKEN_KEY = "authToken";
// 核心数据:就是个字符串
static getToken(): string | null {
return localStorage.getItem(this.TOKEN_KEY);
}
// 从localStorage获取token
public static getToken(): string | null {
return localStorage.getItem("authToken");
static setToken(token: string): void {
localStorage.setItem(this.TOKEN_KEY, token);
}
// 清除token
public static clearToken(): void {
localStorage.removeItem("authToken");
static clearToken(): void {
localStorage.removeItem(this.TOKEN_KEY);
}
// 检查是否已认证
public static async isAuthenticated(): Promise<boolean> {
return await AuthManager.verifyToken();
// 核心功能创建带认证的HTTP配置
static getAuthHeaders(): Record<string, string> {
const token = this.getToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
// 通用的为HTTP请求添加Authorization header的方法
public static addAuthHeader(client: SupportedClient): void {
const token = AuthManager.getToken();
if (token) {
// 创建一个自定义的 http 对象,包装原有的 fetch 方法
const customHttp = {
fetch: (url: RequestInfo, init?: RequestInit) => {
if (!init) init = {};
if (!init.headers) init.headers = {};
// 添加Authorization header
if (typeof init.headers === "object" && init.headers !== null) {
(init.headers as any)["Authorization"] = `Bearer ${token}`;
}
// 使用全局 fetch 或 window.fetch
return (window as any).fetch(url, init);
},
};
// 重新构造客户端,传入自定义的 http 对象
const ClientClass = client.constructor as new (
baseUrl?: string,
http?: any,
) => SupportedClient;
const newClient = new ClientClass(undefined, customHttp);
// 将新客户端的属性复制到原客户端(这是一个 workaround
// 更好的做法是返回新的客户端实例
Object.setPrototypeOf(client, Object.getPrototypeOf(newClient));
Object.assign(client, newClient);
}
}
// 私有方法创建带认证的HTTP客户端
private static createAuthenticatedHttp() {
const token = AuthManager.getToken();
if (!token) {
return null;
}
return {
fetch: (url: RequestInfo, init?: RequestInit) => {
if (!init) init = {};
if (!init.headers) init.headers = {};
if (typeof init.headers === "object" && init.headers !== null) {
(init.headers as any)["Authorization"] = `Bearer ${token}`;
}
return (window as any).fetch(url, init);
},
};
}
// 私有方法创建带认证的Axios实例
private static createAuthenticatedAxiosInstance(): AxiosInstance | null {
const token = AuthManager.getToken();
if (!token) return null;
const instance = axios.create();
instance.interceptors.request.use((config) => {
config.headers = config.headers || {};
(config.headers as any)["Authorization"] = `Bearer ${token}`;
return config;
});
return instance;
}
// 通用的创建已认证客户端的方法(使用泛型)
public static createAuthenticatedClient<T extends SupportedClient>(
ClientClass: new (baseUrl?: string, instance?: AxiosInstance) => T,
// 一个方法搞定所有客户端不要17个垃圾方法
static createClient<T>(
ClientClass: new (baseUrl?: string, config?: any) => T,
baseUrl?: string,
): T {
const axiosInstance = AuthManager.createAuthenticatedAxiosInstance();
return axiosInstance
? new ClientClass(undefined, axiosInstance)
: new ClientClass();
const token = this.getToken();
if (!token) {
return new ClientClass(baseUrl);
}
// 对于axios客户端
const axiosInstance = axios.create({
headers: this.getAuthHeaders(),
});
return new ClientClass(baseUrl, axiosInstance);
}
// 便捷方法:创建已配置认证的各种客户端
public static createAuthenticatedDataClient(): DataClient {
return AuthManager.createAuthenticatedClient(DataClient);
}
public static createAuthenticatedVideoStreamClient(): VideoStreamClient {
return AuthManager.createAuthenticatedClient(VideoStreamClient);
}
public static createAuthenticatedBsdlParserClient(): BsdlParserClient {
return AuthManager.createAuthenticatedClient(BsdlParserClient);
}
public static createAuthenticatedDDSClient(): DDSClient {
return AuthManager.createAuthenticatedClient(DDSClient);
}
public static createAuthenticatedJtagClient(): JtagClient {
return AuthManager.createAuthenticatedClient(JtagClient);
}
public static createAuthenticatedMatrixKeyClient(): MatrixKeyClient {
return AuthManager.createAuthenticatedClient(MatrixKeyClient);
}
public static createAuthenticatedPowerClient(): PowerClient {
return AuthManager.createAuthenticatedClient(PowerClient);
}
public static createAuthenticatedRemoteUpdateClient(): RemoteUpdateClient {
return AuthManager.createAuthenticatedClient(RemoteUpdateClient);
}
public static createAuthenticatedTutorialClient(): TutorialClient {
return AuthManager.createAuthenticatedClient(TutorialClient);
}
public static createAuthenticatedUDPClient(): UDPClient {
return AuthManager.createAuthenticatedClient(UDPClient);
}
public static createAuthenticatedLogicAnalyzerClient(): LogicAnalyzerClient {
return AuthManager.createAuthenticatedClient(LogicAnalyzerClient);
}
public static createAuthenticatedNetConfigClient(): NetConfigClient {
return AuthManager.createAuthenticatedClient(NetConfigClient);
}
public static createAuthenticatedOscilloscopeApiClient(): OscilloscopeApiClient {
return AuthManager.createAuthenticatedClient(OscilloscopeApiClient);
}
public static createAuthenticatedDebuggerClient(): DebuggerClient {
return AuthManager.createAuthenticatedClient(DebuggerClient);
}
public static createAuthenticatedExamClient(): ExamClient {
return AuthManager.createAuthenticatedClient(ExamClient);
}
public static createAuthenticatedResourceClient(): ResourceClient {
return AuthManager.createAuthenticatedClient(ResourceClient);
}
public static createAuthenticatedHdmiVideoStreamClient(): HdmiVideoStreamClient {
return AuthManager.createAuthenticatedClient(HdmiVideoStreamClient);
}
public static createAuthenticatedJtagHubConnection() {
// SignalR连接 - 简单明了
static createHubConnection(
hubPath:
| "ProgressHub"
| "JtagHub"
| "DigitalTubesHub"
| "RotaryEncoderHub"
| "OscilloscopeHub",
) {
return new HubConnectionBuilder()
.withUrl("http://127.0.0.1:5000/hubs/JtagHub", {
.withUrl(`http://127.0.0.1:5000/hubs/${hubPath}`, {
accessTokenFactory: () => this.getToken() ?? "",
})
.withAutomaticReconnect()
.build();
}
public static createAuthenticatedProgressHubConnection() {
return new HubConnectionBuilder()
.withUrl("http://127.0.0.1:5000/hubs/ProgressHub", {
accessTokenFactory: () => this.getToken() ?? "",
})
.withAutomaticReconnect()
.build();
}
// 登录函数
public static async login(
username: string,
password: string,
): Promise<boolean> {
// 认证逻辑 - 去除所有废话
static async login(username: string, password: string): Promise<boolean> {
try {
const client = new DataClient();
const token = await client.login(username, password);
if (token) {
AuthManager.setToken(token);
if (!token) return false;
// 验证token
const authClient = AuthManager.createAuthenticatedDataClient();
await authClient.testAuth();
this.setToken(token);
return true;
}
return false;
} catch (error) {
AuthManager.clearToken();
throw error;
}
}
// 登出函数
public static logout(): void {
AuthManager.clearToken();
}
// 验证当前token是否有效
public static async verifyToken(): Promise<boolean> {
try {
const token = AuthManager.getToken();
if (!token) {
return false;
}
const client = AuthManager.createAuthenticatedDataClient();
await client.testAuth();
// 验证token - 如果失败直接抛异常
await this.createClient(DataClient).testAuth();
return true;
} catch (error) {
AuthManager.clearToken();
return false;
} catch {
this.clearToken();
throw new Error("Login failed");
}
}
// 验证管理员权限
public static async verifyAdminAuth(): Promise<boolean> {
static logout(): void {
this.clearToken();
}
// 简单的验证 - 不要搞复杂
static async isAuthenticated(): Promise<boolean> {
if (!this.getToken()) return false;
try {
const token = AuthManager.getToken();
if (!token) {
return false;
}
const client = AuthManager.createAuthenticatedDataClient();
await client.testAdminAuth();
await this.createClient(DataClient).testAuth();
return true;
} catch (error) {
// 只有在token完全无效的情况下才清除token
// 401错误表示token有效但权限不足不应清除token
if (error && typeof error === "object" && "status" in error) {
// 如果是403 (Forbidden) 或401 (Unauthorized)说明token有效但权限不足
if (error.status === 401 || error.status === 403) {
return false;
}
// 其他状态码可能表示token无效清除token
AuthManager.clearToken();
} else {
// 网络错误等不清除token
console.error("管理员权限验证失败:", error);
}
} catch {
this.clearToken();
return false;
}
}
// 检查客户端是否已配置认证
public static isClientAuthenticated(client: SupportedClient): boolean {
const token = AuthManager.getToken();
return !!token;
static async isAdminAuthenticated(): Promise<boolean> {
if (!this.getToken()) return false;
try {
await this.createClient(DataClient).testAdminAuth();
return true;
} catch {
this.clearToken();
return false;
}
}
}

View File

@@ -17,7 +17,7 @@ export interface BoardData extends Board {
const [useProvideBoardManager, useBoardManager] = createInjectionState(() => {
// 远程升级相关参数
const devPort = 1234;
const remoteUpdater = AuthManager.createAuthenticatedRemoteUpdateClient();
const remoteUpdater = AuthManager.createClient(RemoteUpdateClient);
// 统一的板卡数据
const boards = ref<BoardData[]>([]);
@@ -35,13 +35,13 @@ const [useProvideBoardManager, useBoardManager] = createInjectionState(() => {
async function getAllBoards(): Promise<{ success: boolean; error?: string }> {
try {
// 验证管理员权限
const hasAdminAuth = await AuthManager.verifyAdminAuth();
const hasAdminAuth = await AuthManager.isAdminAuthenticated();
if (!hasAdminAuth) {
console.error("权限验证失败");
return { success: false, error: "权限不足" };
}
const client = AuthManager.createAuthenticatedDataClient();
const client = AuthManager.createClient(DataClient);
const result = await client.getAllBoards();
if (result) {
@@ -77,7 +77,7 @@ const [useProvideBoardManager, useBoardManager] = createInjectionState(() => {
): Promise<{ success: boolean; error?: string; boardId?: string }> {
try {
// 验证管理员权限
const hasAdminAuth = await AuthManager.verifyAdminAuth();
const hasAdminAuth = await AuthManager.isAdminAuthenticated();
if (!hasAdminAuth) {
console.error("权限验证失败");
return { success: false, error: "权限不足" };
@@ -89,11 +89,11 @@ const [useProvideBoardManager, useBoardManager] = createInjectionState(() => {
return { success: false, error: "参数不完整" };
}
const client = AuthManager.createAuthenticatedDataClient();
const client = AuthManager.createClient(DataClient);
const boardId = await client.addBoard(name);
if (boardId) {
console.log("新增板卡成功", { boardId, name});
console.log("新增板卡成功", { boardId, name });
// 刷新板卡列表
await getAllBoards();
return { success: true };
@@ -119,7 +119,7 @@ const [useProvideBoardManager, useBoardManager] = createInjectionState(() => {
): Promise<{ success: boolean; error?: string }> {
try {
// 验证管理员权限
const hasAdminAuth = await AuthManager.verifyAdminAuth();
const hasAdminAuth = await AuthManager.isAdminAuthenticated();
if (!hasAdminAuth) {
console.error("权限验证失败");
return { success: false, error: "权限不足" };
@@ -130,7 +130,7 @@ const [useProvideBoardManager, useBoardManager] = createInjectionState(() => {
return { success: false, error: "板卡ID不能为空" };
}
const client = AuthManager.createAuthenticatedDataClient();
const client = AuthManager.createClient(DataClient);
const result = await client.deleteBoard(boardId);
if (result > 0) {

View File

@@ -59,3 +59,12 @@ export function formatDate(date: Date | string) {
minute: "2-digit",
});
}
export function base64ToArrayBuffer(base64: string) {
var binaryString = atob(base64);
var bytes = new Uint8Array(binaryString.length);
for (var i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}

View File

@@ -0,0 +1,8 @@
/* THIS (.ts) FILE IS GENERATED BY Tapper */
/* eslint-disable */
/* tslint:disable */
/** Transpiled from Database.ResourceTypes */
export type ResourceTypes = {
}

View File

@@ -0,0 +1,16 @@
/* THIS (.ts) FILE IS GENERATED BY Tapper */
/* eslint-disable */
/* tslint:disable */
/** Transpiled from Peripherals.RotaryEncoderClient.RotaryEncoderDirection */
export enum RotaryEncoderDirection {
CounterClockwise = 0,
Clockwise = 1,
}
/** Transpiled from Peripherals.RotaryEncoderClient.RotaryEncoderPressStatus */
export enum RotaryEncoderPressStatus {
Press = 0,
Release = 1,
}

View File

@@ -0,0 +1,14 @@
/* THIS (.ts) FILE IS GENERATED BY Tapper */
/* eslint-disable */
/* tslint:disable */
/** Transpiled from Peripherals.WS2812Client.RGBColor */
export type RGBColor = {
/** Transpiled from byte */
red: number;
/** Transpiled from byte */
green: number;
/** Transpiled from byte */
blue: number;
}

View File

@@ -3,8 +3,10 @@
/* tslint:disable */
// @ts-nocheck
import type { HubConnection, IStreamResult, Subject } from '@microsoft/signalr';
import type { IJtagHub, IProgressHub, IJtagReceiver, IProgressReceiver } from './server.Hubs';
import type { ProgressInfo } from '../server.Hubs';
import type { IDigitalTubesHub, IJtagHub, IOscilloscopeHub, IProgressHub, IRotaryEncoderHub, IWS2812Hub, IDigitalTubesReceiver, IJtagReceiver, IOscilloscopeReceiver, IProgressReceiver, IRotaryEncoderReceiver, IWS2812Receiver } from './server.Hubs';
import type { DigitalTubeTaskStatus, OscilloscopeFullConfig, OscilloscopeDataResponse, ProgressInfo } from '../server.Hubs';
import type { RotaryEncoderDirection, RotaryEncoderPressStatus } from '../Peripherals.RotaryEncoderClient';
import type { RGBColor } from '../Peripherals.WS2812Client';
// components
@@ -43,35 +45,100 @@ class ReceiverMethodSubscription implements Disposable {
// API
export type HubProxyFactoryProvider = {
(hubType: "IDigitalTubesHub"): HubProxyFactory<IDigitalTubesHub>;
(hubType: "IJtagHub"): HubProxyFactory<IJtagHub>;
(hubType: "IOscilloscopeHub"): HubProxyFactory<IOscilloscopeHub>;
(hubType: "IProgressHub"): HubProxyFactory<IProgressHub>;
(hubType: "IRotaryEncoderHub"): HubProxyFactory<IRotaryEncoderHub>;
(hubType: "IWS2812Hub"): HubProxyFactory<IWS2812Hub>;
}
export const getHubProxyFactory = ((hubType: string) => {
if(hubType === "IDigitalTubesHub") {
return IDigitalTubesHub_HubProxyFactory.Instance;
}
if(hubType === "IJtagHub") {
return IJtagHub_HubProxyFactory.Instance;
}
if(hubType === "IOscilloscopeHub") {
return IOscilloscopeHub_HubProxyFactory.Instance;
}
if(hubType === "IProgressHub") {
return IProgressHub_HubProxyFactory.Instance;
}
if(hubType === "IRotaryEncoderHub") {
return IRotaryEncoderHub_HubProxyFactory.Instance;
}
if(hubType === "IWS2812Hub") {
return IWS2812Hub_HubProxyFactory.Instance;
}
}) as HubProxyFactoryProvider;
export type ReceiverRegisterProvider = {
(receiverType: "IDigitalTubesReceiver"): ReceiverRegister<IDigitalTubesReceiver>;
(receiverType: "IJtagReceiver"): ReceiverRegister<IJtagReceiver>;
(receiverType: "IOscilloscopeReceiver"): ReceiverRegister<IOscilloscopeReceiver>;
(receiverType: "IProgressReceiver"): ReceiverRegister<IProgressReceiver>;
(receiverType: "IRotaryEncoderReceiver"): ReceiverRegister<IRotaryEncoderReceiver>;
(receiverType: "IWS2812Receiver"): ReceiverRegister<IWS2812Receiver>;
}
export const getReceiverRegister = ((receiverType: string) => {
if(receiverType === "IDigitalTubesReceiver") {
return IDigitalTubesReceiver_Binder.Instance;
}
if(receiverType === "IJtagReceiver") {
return IJtagReceiver_Binder.Instance;
}
if(receiverType === "IOscilloscopeReceiver") {
return IOscilloscopeReceiver_Binder.Instance;
}
if(receiverType === "IProgressReceiver") {
return IProgressReceiver_Binder.Instance;
}
if(receiverType === "IRotaryEncoderReceiver") {
return IRotaryEncoderReceiver_Binder.Instance;
}
if(receiverType === "IWS2812Receiver") {
return IWS2812Receiver_Binder.Instance;
}
}) as ReceiverRegisterProvider;
// HubProxy
class IDigitalTubesHub_HubProxyFactory implements HubProxyFactory<IDigitalTubesHub> {
public static Instance = new IDigitalTubesHub_HubProxyFactory();
private constructor() {
}
public readonly createHubProxy = (connection: HubConnection): IDigitalTubesHub => {
return new IDigitalTubesHub_HubProxy(connection);
}
}
class IDigitalTubesHub_HubProxy implements IDigitalTubesHub {
public constructor(private connection: HubConnection) {
}
public readonly startScan = async (): Promise<boolean> => {
return await this.connection.invoke("StartScan");
}
public readonly stopScan = async (): Promise<boolean> => {
return await this.connection.invoke("StopScan");
}
public readonly setFrequency = async (frequency: number): Promise<boolean> => {
return await this.connection.invoke("SetFrequency", frequency);
}
public readonly getStatus = async (): Promise<DigitalTubeTaskStatus> => {
return await this.connection.invoke("GetStatus");
}
}
class IJtagHub_HubProxyFactory implements HubProxyFactory<IJtagHub> {
public static Instance = new IJtagHub_HubProxyFactory();
@@ -101,6 +168,55 @@ class IJtagHub_HubProxy implements IJtagHub {
}
}
class IOscilloscopeHub_HubProxyFactory implements HubProxyFactory<IOscilloscopeHub> {
public static Instance = new IOscilloscopeHub_HubProxyFactory();
private constructor() {
}
public readonly createHubProxy = (connection: HubConnection): IOscilloscopeHub => {
return new IOscilloscopeHub_HubProxy(connection);
}
}
class IOscilloscopeHub_HubProxy implements IOscilloscopeHub {
public constructor(private connection: HubConnection) {
}
public readonly initialize = async (config: OscilloscopeFullConfig): Promise<boolean> => {
return await this.connection.invoke("Initialize", config);
}
public readonly startCapture = async (): Promise<boolean> => {
return await this.connection.invoke("StartCapture");
}
public readonly stopCapture = async (): Promise<boolean> => {
return await this.connection.invoke("StopCapture");
}
public readonly getData = async (): Promise<OscilloscopeDataResponse> => {
return await this.connection.invoke("GetData");
}
public readonly setTrigger = async (level: number): Promise<boolean> => {
return await this.connection.invoke("SetTrigger", level);
}
public readonly setRisingEdge = async (risingEdge: boolean): Promise<boolean> => {
return await this.connection.invoke("SetRisingEdge", risingEdge);
}
public readonly setSampling = async (decimationRate: number): Promise<boolean> => {
return await this.connection.invoke("SetSampling", decimationRate);
}
public readonly setFrequency = async (frequency: number): Promise<boolean> => {
return await this.connection.invoke("SetFrequency", frequency);
}
}
class IProgressHub_HubProxyFactory implements HubProxyFactory<IProgressHub> {
public static Instance = new IProgressHub_HubProxyFactory();
@@ -120,11 +236,102 @@ class IProgressHub_HubProxy implements IProgressHub {
public readonly join = async (taskId: string): Promise<boolean> => {
return await this.connection.invoke("Join", taskId);
}
public readonly leave = async (taskId: string): Promise<boolean> => {
return await this.connection.invoke("Leave", taskId);
}
public readonly getProgress = async (taskId: string): Promise<ProgressInfo> => {
return await this.connection.invoke("GetProgress", taskId);
}
}
class IRotaryEncoderHub_HubProxyFactory implements HubProxyFactory<IRotaryEncoderHub> {
public static Instance = new IRotaryEncoderHub_HubProxyFactory();
private constructor() {
}
public readonly createHubProxy = (connection: HubConnection): IRotaryEncoderHub => {
return new IRotaryEncoderHub_HubProxy(connection);
}
}
class IRotaryEncoderHub_HubProxy implements IRotaryEncoderHub {
public constructor(private connection: HubConnection) {
}
public readonly setEnable = async (enable: boolean): Promise<boolean> => {
return await this.connection.invoke("SetEnable", enable);
}
public readonly rotateEncoderOnce = async (num: number, direction: RotaryEncoderDirection): Promise<boolean> => {
return await this.connection.invoke("RotateEncoderOnce", num, direction);
}
public readonly pressEncoderOnce = async (num: number, press: RotaryEncoderPressStatus): Promise<boolean> => {
return await this.connection.invoke("PressEncoderOnce", num, press);
}
public readonly enableCycleRotateEncoder = async (num: number, direction: RotaryEncoderDirection, freq: number): Promise<boolean> => {
return await this.connection.invoke("EnableCycleRotateEncoder", num, direction, freq);
}
public readonly disableCycleRotateEncoder = async (): Promise<boolean> => {
return await this.connection.invoke("DisableCycleRotateEncoder");
}
}
class IWS2812Hub_HubProxyFactory implements HubProxyFactory<IWS2812Hub> {
public static Instance = new IWS2812Hub_HubProxyFactory();
private constructor() {
}
public readonly createHubProxy = (connection: HubConnection): IWS2812Hub => {
return new IWS2812Hub_HubProxy(connection);
}
}
class IWS2812Hub_HubProxy implements IWS2812Hub {
public constructor(private connection: HubConnection) {
}
public readonly getAllLedColors = async (): Promise<RGBColor[]> => {
return await this.connection.invoke("GetAllLedColors");
}
public readonly getLedColor = async (ledIndex: number): Promise<RGBColor> => {
return await this.connection.invoke("GetLedColor", ledIndex);
}
}
// Receiver
class IDigitalTubesReceiver_Binder implements ReceiverRegister<IDigitalTubesReceiver> {
public static Instance = new IDigitalTubesReceiver_Binder();
private constructor() {
}
public readonly register = (connection: HubConnection, receiver: IDigitalTubesReceiver): Disposable => {
const __onReceive = (...args: [string]) => receiver.onReceive(...args);
connection.on("OnReceive", __onReceive);
const methodList: ReceiverMethod[] = [
{ methodName: "OnReceive", method: __onReceive }
]
return new ReceiverMethodSubscription(connection, methodList);
}
}
class IJtagReceiver_Binder implements ReceiverRegister<IJtagReceiver> {
public static Instance = new IJtagReceiver_Binder();
@@ -146,6 +353,27 @@ class IJtagReceiver_Binder implements ReceiverRegister<IJtagReceiver> {
}
}
class IOscilloscopeReceiver_Binder implements ReceiverRegister<IOscilloscopeReceiver> {
public static Instance = new IOscilloscopeReceiver_Binder();
private constructor() {
}
public readonly register = (connection: HubConnection, receiver: IOscilloscopeReceiver): Disposable => {
const __onDataReceived = (...args: [OscilloscopeDataResponse]) => receiver.onDataReceived(...args);
connection.on("OnDataReceived", __onDataReceived);
const methodList: ReceiverMethod[] = [
{ methodName: "OnDataReceived", method: __onDataReceived }
]
return new ReceiverMethodSubscription(connection, methodList);
}
}
class IProgressReceiver_Binder implements ReceiverRegister<IProgressReceiver> {
public static Instance = new IProgressReceiver_Binder();
@@ -167,3 +395,45 @@ class IProgressReceiver_Binder implements ReceiverRegister<IProgressReceiver> {
}
}
class IRotaryEncoderReceiver_Binder implements ReceiverRegister<IRotaryEncoderReceiver> {
public static Instance = new IRotaryEncoderReceiver_Binder();
private constructor() {
}
public readonly register = (connection: HubConnection, receiver: IRotaryEncoderReceiver): Disposable => {
const __onReceiveRotate = (...args: [number, RotaryEncoderDirection]) => receiver.onReceiveRotate(...args);
connection.on("OnReceiveRotate", __onReceiveRotate);
const methodList: ReceiverMethod[] = [
{ methodName: "OnReceiveRotate", method: __onReceiveRotate }
]
return new ReceiverMethodSubscription(connection, methodList);
}
}
class IWS2812Receiver_Binder implements ReceiverRegister<IWS2812Receiver> {
public static Instance = new IWS2812Receiver_Binder();
private constructor() {
}
public readonly register = (connection: HubConnection, receiver: IWS2812Receiver): Disposable => {
const __onReceive = (...args: [RGBColor[]]) => receiver.onReceive(...args);
connection.on("OnReceive", __onReceive);
const methodList: ReceiverMethod[] = [
{ methodName: "OnReceive", method: __onReceive }
]
return new ReceiverMethodSubscription(connection, methodList);
}
}

View File

@@ -3,7 +3,29 @@
/* tslint:disable */
// @ts-nocheck
import type { IStreamResult, Subject } from '@microsoft/signalr';
import type { ProgressInfo } from '../server.Hubs';
import type { DigitalTubeTaskStatus, OscilloscopeFullConfig, OscilloscopeDataResponse, ProgressInfo } from '../server.Hubs';
import type { RotaryEncoderDirection, RotaryEncoderPressStatus } from '../Peripherals.RotaryEncoderClient';
import type { RGBColor } from '../Peripherals.WS2812Client';
export type IDigitalTubesHub = {
/**
* @returns Transpiled from System.Threading.Tasks.Task<bool>
*/
startScan(): Promise<boolean>;
/**
* @returns Transpiled from System.Threading.Tasks.Task<bool>
*/
stopScan(): Promise<boolean>;
/**
* @param frequency Transpiled from int
* @returns Transpiled from System.Threading.Tasks.Task<bool>
*/
setFrequency(frequency: number): Promise<boolean>;
/**
* @returns Transpiled from System.Threading.Tasks.Task<server.Hubs.DigitalTubeTaskStatus?>
*/
getStatus(): Promise<DigitalTubeTaskStatus>;
}
export type IJtagHub = {
/**
@@ -22,12 +44,113 @@ export type IJtagHub = {
stopBoundaryScan(): Promise<boolean>;
}
export type IOscilloscopeHub = {
/**
* @param config Transpiled from server.Hubs.OscilloscopeFullConfig
* @returns Transpiled from System.Threading.Tasks.Task<bool>
*/
initialize(config: OscilloscopeFullConfig): Promise<boolean>;
/**
* @returns Transpiled from System.Threading.Tasks.Task<bool>
*/
startCapture(): Promise<boolean>;
/**
* @returns Transpiled from System.Threading.Tasks.Task<bool>
*/
stopCapture(): Promise<boolean>;
/**
* @returns Transpiled from System.Threading.Tasks.Task<server.Hubs.OscilloscopeDataResponse?>
*/
getData(): Promise<OscilloscopeDataResponse>;
/**
* @param level Transpiled from byte
* @returns Transpiled from System.Threading.Tasks.Task<bool>
*/
setTrigger(level: number): Promise<boolean>;
/**
* @param risingEdge Transpiled from bool
* @returns Transpiled from System.Threading.Tasks.Task<bool>
*/
setRisingEdge(risingEdge: boolean): Promise<boolean>;
/**
* @param decimationRate Transpiled from ushort
* @returns Transpiled from System.Threading.Tasks.Task<bool>
*/
setSampling(decimationRate: number): Promise<boolean>;
/**
* @param frequency Transpiled from int
* @returns Transpiled from System.Threading.Tasks.Task<bool>
*/
setFrequency(frequency: number): Promise<boolean>;
}
export type IProgressHub = {
/**
* @param taskId Transpiled from string
* @returns Transpiled from System.Threading.Tasks.Task<bool>
*/
join(taskId: string): Promise<boolean>;
/**
* @param taskId Transpiled from string
* @returns Transpiled from System.Threading.Tasks.Task<bool>
*/
leave(taskId: string): Promise<boolean>;
/**
* @param taskId Transpiled from string
* @returns Transpiled from System.Threading.Tasks.Task<server.Hubs.ProgressInfo?>
*/
getProgress(taskId: string): Promise<ProgressInfo>;
}
export type IRotaryEncoderHub = {
/**
* @param enable Transpiled from bool
* @returns Transpiled from System.Threading.Tasks.Task<bool>
*/
setEnable(enable: boolean): Promise<boolean>;
/**
* @param num Transpiled from int
* @param direction Transpiled from Peripherals.RotaryEncoderClient.RotaryEncoderDirection
* @returns Transpiled from System.Threading.Tasks.Task<bool>
*/
rotateEncoderOnce(num: number, direction: RotaryEncoderDirection): Promise<boolean>;
/**
* @param num Transpiled from int
* @param press Transpiled from Peripherals.RotaryEncoderClient.RotaryEncoderPressStatus
* @returns Transpiled from System.Threading.Tasks.Task<bool>
*/
pressEncoderOnce(num: number, press: RotaryEncoderPressStatus): Promise<boolean>;
/**
* @param num Transpiled from int
* @param direction Transpiled from Peripherals.RotaryEncoderClient.RotaryEncoderDirection
* @param freq Transpiled from int
* @returns Transpiled from System.Threading.Tasks.Task<bool>
*/
enableCycleRotateEncoder(num: number, direction: RotaryEncoderDirection, freq: number): Promise<boolean>;
/**
* @returns Transpiled from System.Threading.Tasks.Task<bool>
*/
disableCycleRotateEncoder(): Promise<boolean>;
}
export type IWS2812Hub = {
/**
* @returns Transpiled from System.Threading.Tasks.Task<Peripherals.WS2812Client.RGBColor[]?>
*/
getAllLedColors(): Promise<RGBColor[]>;
/**
* @param ledIndex Transpiled from int
* @returns Transpiled from System.Threading.Tasks.Task<Peripherals.WS2812Client.RGBColor?>
*/
getLedColor(ledIndex: number): Promise<RGBColor>;
}
export type IDigitalTubesReceiver = {
/**
* @param data Transpiled from byte[]
* @returns Transpiled from System.Threading.Tasks.Task
*/
onReceive(data: string): Promise<void>;
}
export type IJtagReceiver = {
@@ -38,6 +161,14 @@ export type IJtagReceiver = {
onReceiveBoundaryScanData(msg: Partial<Record<string, boolean>>): Promise<void>;
}
export type IOscilloscopeReceiver = {
/**
* @param data Transpiled from server.Hubs.OscilloscopeDataResponse
* @returns Transpiled from System.Threading.Tasks.Task
*/
onDataReceived(data: OscilloscopeDataResponse): Promise<void>;
}
export type IProgressReceiver = {
/**
* @param message Transpiled from server.Hubs.ProgressInfo
@@ -46,3 +177,20 @@ export type IProgressReceiver = {
onReceiveProgress(message: ProgressInfo): Promise<void>;
}
export type IRotaryEncoderReceiver = {
/**
* @param num Transpiled from int
* @param direction Transpiled from Peripherals.RotaryEncoderClient.RotaryEncoderDirection
* @returns Transpiled from System.Threading.Tasks.Task
*/
onReceiveRotate(num: number, direction: RotaryEncoderDirection): Promise<void>;
}
export type IWS2812Receiver = {
/**
* @param data Transpiled from Peripherals.WS2812Client.RGBColor[]
* @returns Transpiled from System.Threading.Tasks.Task
*/
onReceive(data: RGBColor[]): Promise<void>;
}

View File

@@ -2,13 +2,50 @@
/* eslint-disable */
/* tslint:disable */
/** Transpiled from server.Hubs.DigitalTubeTaskStatus */
export type DigitalTubeTaskStatus = {
/** Transpiled from int */
frequency: number;
/** Transpiled from bool */
isRunning: boolean;
}
/** Transpiled from server.Hubs.OscilloscopeDataResponse */
export type OscilloscopeDataResponse = {
/** Transpiled from uint */
adFrequency: number;
/** Transpiled from byte */
adVpp: number;
/** Transpiled from byte */
adMax: number;
/** Transpiled from byte */
adMin: number;
/** Transpiled from string */
waveformData: string;
}
/** Transpiled from server.Hubs.OscilloscopeFullConfig */
export type OscilloscopeFullConfig = {
/** Transpiled from bool */
captureEnabled: boolean;
/** Transpiled from byte */
triggerLevel: number;
/** Transpiled from bool */
triggerRisingEdge: boolean;
/** Transpiled from ushort */
horizontalShift: number;
/** Transpiled from ushort */
decimationRate: number;
/** Transpiled from int */
captureFrequency: number;
}
/** Transpiled from server.Hubs.ProgressStatus */
export enum ProgressStatus {
Pending = 0,
InProgress = 1,
Completed = 2,
Canceled = 3,
Failed = 4,
Running = 0,
Completed = 1,
Canceled = 2,
Failed = 3,
}
/** Transpiled from server.Hubs.ProgressInfo */
@@ -17,9 +54,15 @@ export type ProgressInfo = {
taskId: string;
/** Transpiled from server.Hubs.ProgressStatus */
status: ProgressStatus;
/** Transpiled from int */
/** Transpiled from double */
progressPercent: number;
/** Transpiled from string */
errorMessage: string;
}
/** Transpiled from server.Hubs.WS2812TaskStatus */
export type WS2812TaskStatus = {
/** Transpiled from bool */
isRunning: boolean;
}

View File

@@ -274,7 +274,7 @@ const handleSignUp = async () => {
// 页面初始化时检查是否已有有效token
const checkExistingToken = async () => {
try {
const isValid = await AuthManager.verifyToken();
const isValid = await AuthManager.isAuthenticated();
if (isValid) {
// 如果token仍然有效直接跳转到project页面
router.go(-1);

View File

@@ -8,19 +8,7 @@
{{ mode === "create" ? "新建实验" : "编辑实验" }}
</h2>
<button @click="close" class="btn btn-sm btn-circle btn-ghost">
<svg
class="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<XIcon class="w-6 h-6" />
</button>
</div>
@@ -194,7 +182,7 @@
<!-- MD文档 -->
<div class="space-y-2">
<label class="text-sm font-medium text-base-content"
>MD文档 (必需)</label
>MD文档 (可选)</label
>
<div
class="border-2 border-dashed border-base-300 rounded-lg p-6 text-center cursor-pointer hover:border-primary hover:bg-primary/5 transition-colors aspect-square flex items-center justify-center"
@@ -417,8 +405,15 @@ import {
BinaryIcon,
FileArchiveIcon,
FileJsonIcon,
XIcon,
} from "lucide-vue-next";
import { ExamDto, type FileParameter } from "@/APIClient";
import {
ExamClient,
ExamDto,
ResourceClient,
ResourcePurpose,
type FileParameter,
} from "@/APIClient";
import { useAlertStore } from "@/components/Alert";
import { AuthManager } from "@/utils/AuthManager";
import { useRequiredInjection } from "@/utils/Common";
@@ -473,7 +468,7 @@ const canCreateExam = computed(() => {
editExamInfo.value.id.trim() !== "" &&
editExamInfo.value.name.trim() !== "" &&
editExamInfo.value.description.trim() !== "" &&
(uploadFiles.value.mdFile !== null || mode.value === "edit")
(mode.value === "edit")
);
});
@@ -610,15 +605,10 @@ const submitCreateExam = async () => {
return;
}
if (!uploadFiles.value.mdFile) {
alert.error("请上传MD文档");
return;
}
isUpdating.value = true;
try {
const client = AuthManager.createAuthenticatedExamClient();
const client = AuthManager.createClient(ExamClient);
let exam: ExamInfo;
if (mode.value === "create") {
@@ -671,7 +661,7 @@ const submitCreateExam = async () => {
// 上传实验资源
async function uploadExamResources(examId: string) {
const client = AuthManager.createAuthenticatedResourceClient();
const client = AuthManager.createClient(ResourceClient);
try {
// 上传MD文档
@@ -680,7 +670,12 @@ async function uploadExamResources(examId: string) {
data: uploadFiles.value.mdFile,
fileName: uploadFiles.value.mdFile.name,
};
await client.addResource("doc", "template", examId, mdFileParam);
await client.addResource(
"doc",
ResourcePurpose.Template,
examId,
mdFileParam,
);
console.log("MD文档上传成功");
}
@@ -690,7 +685,12 @@ async function uploadExamResources(examId: string) {
data: imageFile,
fileName: imageFile.name,
};
await client.addResource("image", "template", examId, imageFileParam);
await client.addResource(
"image",
ResourcePurpose.Template,
examId,
imageFileParam,
);
console.log("图片上传成功:", imageFile.name);
}
@@ -702,7 +702,7 @@ async function uploadExamResources(examId: string) {
};
await client.addResource(
"bitstream",
"template",
ResourcePurpose.Template,
examId,
bitstreamFileParam,
);
@@ -715,7 +715,12 @@ async function uploadExamResources(examId: string) {
data: canvasFile,
fileName: canvasFile.name,
};
await client.addResource("canvas", "template", examId, canvasFileParam);
await client.addResource(
"canvas",
ResourcePurpose.Template,
examId,
canvasFileParam,
);
console.log("画布模板上传成功:", canvasFile.name);
}
@@ -727,7 +732,7 @@ async function uploadExamResources(examId: string) {
};
await client.addResource(
"resource",
"template",
ResourcePurpose.Template,
examId,
resourceFileParam,
);
@@ -750,7 +755,7 @@ function close() {
}
async function editExam(examId: string) {
const client = AuthManager.createAuthenticatedExamClient();
const client = AuthManager.createClient(ExamClient);
const examInfo = await client.getExam(examId);
editExamInfo.value = {
@@ -770,6 +775,7 @@ defineExpose({
show,
close,
editExam,
editExamInfo,
});
</script>

View File

@@ -13,19 +13,7 @@
@click="closeExamDetail"
class="btn btn-sm btn-circle btn-ghost"
>
<svg
class="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<XIcon class="w-6 h-6" />
</button>
</div>
@@ -147,17 +135,18 @@
<div class="space-y-4">
<div class="flex justify-between items-center">
<span class="text-base-content/70">当前状态</span>
<div class="badge badge-error">未完成</div>
</div>
<div class="flex justify-between items-center">
<span class="text-base-content/70">批阅状态</span>
<div class="badge badge-ghost">待提交</div>
<div class="badge badge-error">
{{
isUndefined(commitsList) || commitsList.length === 0
? "未提交"
: "已提交"
}}
</div>
</div>
<div class="flex justify-between items-center">
<span class="text-base-content/70">成绩</span>
<span class="text-base-content/50">未评分</span>
<div class="badge badge-ghost">未评分</div>
</div>
</div>
@@ -167,17 +156,22 @@
<div class="space-y-3">
<h4 class="font-medium text-base-content">提交历史</h4>
<div
v-if="isUndefined(commitsList)"
v-if="isUndefined(commitsList) || commitsList.length === 0"
class="text-sm text-base-content/50 text-center py-4"
>
暂无提交记录
</div>
<div v-else class="overflow-y-auto">
<div v-else class="overflow-y-auto fit-content max-h-50">
<ul class="steps steps-vertical">
<li class="step step-primary">Register</li>
<li class="step step-primary">Choose plan</li>
<li class="step">Purchase</li>
<li class="step">Receive Product</li>
<li
class="step"
:class="{
'step-primary': _idx === commitsList.length - 1,
}"
v-for="(commit, _idx) in commitsList"
>
{{ commit.uploadTime.toTimeString() }}
</li>
</ul>
</div>
</div>
@@ -187,58 +181,27 @@
<!-- 操作按钮 -->
<div class="space-y-3">
<button @click="startExam" class="btn btn-primary w-full">
<svg
class="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M14.828 14.828a4 4 0 01-5.656 0M9 10h1m4 0h1m-6 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<Smile class="w-5 h-5" />
开始实验
</button>
<button @click="uploadModal?.show" class="btn btn-info w-full">
<Upload class="w-5 h-5" />
提交实验
</button>
<button
@click="downloadResources"
class="btn btn-outline w-full"
:disabled="downloadingResources"
>
<svg
class="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<Download class="w-5 h-5" />
<span v-if="downloadingResources">下载中...</span>
<span v-else>下载资源包</span>
</button>
<button class="btn btn-outline w-full">
<svg
class="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<GitGraph class="w-5 h-5" />
查看记录
</button>
</div>
@@ -247,10 +210,24 @@
</div>
</div>
<div class="modal-backdrop" @click="closeExamDetail"></div>
<UploadModal
ref="uploadModal"
class="fixed z-auto"
:auto-upload="true"
:close-after-upload="true"
:callback="submitExam"
@finished-upload="handleSubmitFinished"
/>
</div>
</template>
<script setup lang="ts">
import type { Commit, ExamInfo } from "@/APIClient";
import {
ExamClient,
ResourceClient,
ResourcePurpose,
type ExamInfo,
type ResourceInfo,
} from "@/APIClient";
import { useAlertStore } from "@/components/Alert";
import { AuthManager } from "@/utils/AuthManager";
import { useRequiredInjection } from "@/utils/Common";
@@ -259,11 +236,18 @@ import { useRouter } from "vue-router";
import { formatDate } from "@/utils/Common";
import { computed } from "vue";
import { watch } from "vue";
import { isNull, isUndefined } from "lodash";
import { delay, isNull, isUndefined } from "lodash";
import { Download, GitGraph, Smile, Upload, XIcon } from "lucide-vue-next";
import UploadModal from "@/components/UploadModal.vue";
import { templateRef } from "@vueuse/core";
import { toFileParameter } from "@/utils/Common";
import { onMounted } from "vue";
const alertStore = useRequiredInjection(useAlertStore);
const router = useRouter();
const uploadModal = templateRef("uploadModal");
const show = defineModel<boolean>("show", {
default: false,
});
@@ -272,29 +256,41 @@ const props = defineProps<{
selectedExam: ExamInfo;
}>();
const commitsList = ref<Commit[]>();
const commitsList = ref<ResourceInfo[]>();
async function updateCommits() {
const client = AuthManager.createAuthenticatedExamClient();
const client = AuthManager.createClient(ExamClient);
const list = await client.getCommitsByExamId(props.selectedExam.id);
commitsList.value = list;
}
watch(() => props.selectedExam, updateCommits);
watch(
() => show.value,
() => {
if (show.value) {
updateCommits();
}
},
);
onMounted(() => {
if (show.value) {
updateCommits();
}
});
// Download resources
const downloadingResources = ref(false);
const downloadResources = async () => {
async function downloadResources() {
if (!props.selectedExam || downloadingResources.value) return;
downloadingResources.value = true;
try {
const resourceClient = AuthManager.createAuthenticatedResourceClient();
const resourceClient = AuthManager.createClient(ResourceClient);
// 获取资源包列表(模板资源)
const resourceList = await resourceClient.getResourceList(
props.selectedExam.id,
"resource",
"template",
ResourcePurpose.Template,
);
if (resourceList && resourceList.length > 0) {
@@ -330,10 +326,10 @@ const downloadResources = async () => {
} finally {
downloadingResources.value = false;
}
};
}
// 开始实验
const startExam = () => {
function startExam() {
if (props.selectedExam) {
// 跳转到项目页面传递实验ID
console.log("开始实验:", props.selectedExam.id);
@@ -342,11 +338,35 @@ const startExam = () => {
query: { examId: props.selectedExam.id },
});
}
};
}
const closeExamDetail = () => {
function submitExam(files: File[]) {
try {
const client = AuthManager.createClient(ResourceClient);
for (const file of files) {
client.addResource(
"compression",
ResourcePurpose.Homework,
props.selectedExam.id,
toFileParameter(file),
);
}
} catch (err: any) {
alertStore.error(err.message || "上传资料失败");
console.error("上传资料失败:", err);
}
}
async function handleSubmitFinished() {
delay(async () => {
await updateCommits();
}, 1000);
}
function closeExamDetail() {
show.value = false;
};
}
</script>
<style lang="postcss" scoped></style>

View File

@@ -172,6 +172,7 @@
<!-- 创建实验模态框 -->
<ExamEditModal
ref="examEditModalRef"
v-model:is-show-modal="showEditModal"
@edit-finished="handleEditExamFinished"
/>
</div>
@@ -179,18 +180,23 @@
<script setup lang="ts">
import { ref, onMounted, computed } from "vue";
import { useRoute } from "vue-router";
import { useRoute, useRouter } from "vue-router";
import { AuthManager } from "@/utils/AuthManager";
import { type ExamInfo } from "@/APIClient";
import { ExamClient, type ExamInfo } from "@/APIClient";
import { formatDate } from "@/utils/Common";
import ExamInfoModal from "./ExamInfoModal.vue";
import ExamEditModal from "./ExamEditModal.vue";
import router from "@/router";
import { EditIcon } from "lucide-vue-next";
import { templateRef } from "@vueuse/core";
import { isArray, isNull } from "lodash";
import { watch } from "vue";
import { watchEffect } from "vue";
import { nextTick } from "vue";
const router = useRouter();
const route = useRoute();
// 响应式数据
const route = useRoute();
const exams = ref<ExamInfo[]>([]);
const selectedExam = ref<ExamInfo | null>(null);
const loading = ref(false);
@@ -200,13 +206,38 @@ const isAdmin = ref(false);
// Modal
const examEditModalRef = templateRef("examEditModalRef");
const showInfoModal = ref(false);
const showEditModal = ref(false);
watch(
() => showInfoModal.value,
() => {
if (isNull(selectedExam.value) || showInfoModal.value == false) {
router.replace({ path: "/exam" });
} else {
router.replace({ path: `/exam/${selectedExam.value.id}` });
}
},
);
watch(
() => showEditModal.value,
() => {
if (showEditModal.value) {
router.replace({
path: `/exam/edit/${examEditModalRef.value?.editExamInfo.id}`,
});
} else {
router.replace({ path: `/exam` });
}
},
);
async function refreshExams() {
loading.value = true;
error.value = "";
try {
const client = AuthManager.createAuthenticatedExamClient();
const client = AuthManager.createClient(ExamClient);
exams.value = await client.getExamList();
} catch (err: any) {
error.value = err.message || "获取实验列表失败";
@@ -218,7 +249,7 @@ async function refreshExams() {
async function viewExam(examId: string) {
try {
const client = AuthManager.createAuthenticatedExamClient();
const client = AuthManager.createClient(ExamClient);
selectedExam.value = await client.getExam(examId);
showInfoModal.value = true;
} catch (err: any) {
@@ -238,7 +269,8 @@ async function handleCardClicked(event: MouseEvent, examId: string) {
}
async function handleEditExamClicked(event: MouseEvent, examId: string) {
examEditModalRef?.value?.editExam(examId);
await examEditModalRef?.value?.editExam(examId);
router.replace(`/exam/edit/${examId}`);
}
// 生命周期
@@ -248,14 +280,33 @@ onMounted(async () => {
router.push("/login");
}
isAdmin.value = await AuthManager.verifyAdminAuth();
isAdmin.value = await AuthManager.isAdminAuthenticated();
await refreshExams();
});
async function loadBasicPage(page: string) {
if (page === "") return;
else if (page === "edit") showEditModal.value = true;
else if (page) await viewExam(page);
else router.push("/exam");
}
onMounted(async () => {
// 处理路由参数如果有examId则自动打开该实验的详情模态框
const examId = route.query.examId as string;
if (examId) {
await viewExam(examId);
const page = route.params.page;
if (Array.isArray(page)) {
if (page.length == 1) await loadBasicPage(page[0]);
else if (page.length == 2) {
if (page[0] === "edit") {
await examEditModalRef.value?.editExam(page[1]);
} else {
router.push("/exam");
}
} else router.push("/exam");
} else {
await loadBasicPage(page);
}
});
</script>

View File

@@ -1,99 +1,153 @@
<template>
<div class="h-full flex flex-col gap-7">
<div class="tabs tabs-lift flex-shrink-0 mx-5">
<label class="tab">
<input
type="radio"
name="function-bar"
id="1"
:checked="checkID === 1"
@change="handleTabChange"
/>
<TerminalIcon class="icon" />
日志终端
</label>
<label class="tab">
<input
type="radio"
name="function-bar"
id="2"
:checked="checkID === 2"
@change="handleTabChange"
/>
<VideoIcon class="icon" />
HTTP视频流
</label>
<label class="tab">
<input
type="radio"
name="function-bar"
id="3"
:checked="checkID === 3"
@change="handleTabChange"
/>
<Monitor class="icon" />
HDMI视频流
</label>
<label class="tab">
<input
type="radio"
name="function-bar"
id="4"
:checked="checkID === 4"
@change="handleTabChange"
/>
<SquareActivityIcon class="icon" />
示波器
</label>
<label class="tab">
<input
type="radio"
name="function-bar"
id="5"
:checked="checkID === 5"
@change="handleTabChange"
/>
<Binary class="icon" />
逻辑分析仪
</label>
<label class="tab">
<input
type="radio"
name="function-bar"
id="6"
:checked="checkID === 6"
@change="handleTabChange"
/>
<Hand class="icon" />
嵌入式逻辑分析仪
</label>
<!-- 全屏按钮 -->
<button
class="fullscreen-btn ml-auto btn btn-ghost btn-sm"
@click="toggleFullscreen"
:title="isFullscreen ? '退出全屏' : '全屏'"
<div class="h-full flex flex-col gap-4">
<!-- 标签栏 -->
<div class="tabs-container mx-5">
<div
class="tabs tabs-lift flex-shrink-0 bg-base-100 rounded-xl shadow-lg border border-base-300"
>
<MaximizeIcon v-if="!isFullscreen" class="icon" />
<MinimizeIcon v-else class="icon" />
</button>
<label
v-for="tab in tabs"
:key="tab.id"
class="tab-item"
:class="{ 'tab-active': checkID === tab.id }"
>
<input
type="radio"
name="function-bar"
:id="tab.id.toString()"
:checked="checkID === tab.id"
@change="handleTabChange"
class="hidden"
/>
<component :is="tab.icon" class="icon" />
<span class="tab-label">{{ tab.label }}</span>
<!-- 活跃指示器 -->
<div class="active-indicator" v-if="checkID === tab.id"></div>
</label>
<!-- 全屏按钮 -->
<div class="fullscreen-container ml-auto">
<button
class="fullscreen-btn"
@click="toggleFullscreen"
:title="isFullscreen ? '退出全屏' : '全屏'"
>
<MaximizeIcon v-if="!isFullscreen" class="icon" />
<MinimizeIcon v-else class="icon" />
<span class="btn-tooltip">{{
isFullscreen ? "退出全屏" : "全屏"
}}</span>
</button>
</div>
</div>
</div>
<!-- 主页面 -->
<div class="flex-1 overflow-hidden">
<div v-if="checkID === 1" class="h-full overflow-y-auto"></div>
<div v-else-if="checkID === 2" class="h-full overflow-y-auto">
<VideoStreamView />
</div>
<div v-else-if="checkID === 3" class="h-full overflow-y-auto">
<HdmiVideoStreamView />
</div>
<div v-else-if="checkID === 4" class="h-full overflow-y-auto">
<OscilloscopeView />
</div>
<div v-else-if="checkID === 5" class="h-full overflow-y-auto">
<LogicAnalyzerView />
</div>
<div v-else-if="checkID === 6" class="h-full overflow-y-auto">
<Debugger />
<!-- 主内容区域 -->
<div class="content-area flex-1 overflow-hidden mx-5 mb-5">
<div
class="content-wrapper bg-base-100 rounded-xl shadow-lg border border-base-300 h-full overflow-hidden"
>
<!-- 加载状态 -->
<div v-if="isLoading" class="loading-container">
<span class="loading loading-spinner loading-xl"></span>
<p class="loading-text">正在加载 {{ getCurrentTabLabel }}...</p>
</div>
<!-- 内容区域 -->
<div v-else class="content-panel h-full overflow-hidden">
<Transition name="fade" mode="out-in">
<div :key="checkID" class="h-full overflow-y-auto">
<!-- 日志终端 -->
<div v-if="checkID === 1" class="panel-content">
<div class="panel-header">
<TerminalIcon class="panel-icon" />
<h3 class="panel-title">日志终端</h3>
</div>
<div class="terminal-placeholder">
<p class="placeholder-text">日志终端功能正在开发中...</p>
</div>
</div>
<!-- HTTP视频流 -->
<Suspense v-else-if="checkID === 2">
<template #default>
<VideoStreamView />
</template>
<template #fallback>
<div class="loading-fallback">
<span class="loading loading-spinner loading-xl"></span>
<p class="loading-text">正在加载视频流组件...</p>
</div>
</template>
</Suspense>
<!-- HDMI视频流 -->
<Suspense v-else-if="checkID === 3">
<template #default>
<HdmiVideoStreamView />
</template>
<template #fallback>
<div class="loading-fallback">
<span class="loading loading-spinner loading-xl"></span>
<p class="loading-text">正在加载HDMI视频流组件...</p>
</div>
</template>
</Suspense>
<!-- 示波器 -->
<Suspense v-else-if="checkID === 4">
<template #default>
<OscilloscopeView />
</template>
<template #fallback>
<div class="loading-fallback">
<span class="loading loading-spinner loading-xl"></span>
<p class="loading-text">正在加载示波器组件...</p>
</div>
</template>
</Suspense>
<!-- 逻辑分析仪 -->
<Suspense v-else-if="checkID === 5">
<template #default>
<LogicAnalyzerView />
</template>
<template #fallback>
<div class="loading-fallback">
<span class="loading loading-spinner loading-xl"></span>
<p class="loading-text">正在加载逻辑分析仪组件...</p>
</div>
</template>
</Suspense>
<!-- 嵌入式逻辑分析仪 -->
<Suspense v-else-if="checkID === 6">
<template #default>
<DebuggerView />
</template>
<template #fallback>
<div class="loading-fallback">
<span class="loading loading-spinner loading-xl"></span>
<p class="loading-text">正在加载嵌入式逻辑分析仪组件...</p>
</div>
</template>
</Suspense>
<!-- 信号发生器 -->
<Suspense v-else-if="checkID === 7">
<template #default>
<DDSCtrlView />
</template>
<template #fallback>
<div class="loading-fallback">
<span class="loading loading-spinner loading-xl"></span>
<p class="loading-text">正在加载信号发生器组件...</p>
</div>
</template>
</Suspense>
</div>
</Transition>
</div>
</div>
</div>
</div>
@@ -109,22 +163,46 @@ import {
Binary,
Hand,
Monitor,
Signature,
} from "lucide-vue-next";
import { useLocalStorage } from "@vueuse/core";
import VideoStreamView from "@/views/Project/VideoStream.vue";
import HdmiVideoStreamView from "@/views/Project/HdmiVideoStream.vue";
import OscilloscopeView from "@/views/Project/Oscilloscope.vue";
import LogicAnalyzerView from "@/views/Project/LogicAnalyzer.vue";
import { isNull, toNumber } from "lodash";
import { onMounted, ref, watch } from "vue";
import Debugger from "./Debugger.vue";
import { onMounted, ref, watch, defineAsyncComponent, computed } from "vue";
import { useProvideLogicAnalyzer } from "@/components/LogicAnalyzer";
import { useProvideOscilloscope } from "@/components/Oscilloscope/OscilloscopeManager";
// 懒加载组件
const VideoStreamView = defineAsyncComponent(
() => import("@/views/Project/VideoStream.vue"),
);
const HdmiVideoStreamView = defineAsyncComponent(
() => import("@/views/Project/HdmiVideoStream.vue"),
);
const OscilloscopeView = defineAsyncComponent(
() => import("@/views/Project/Oscilloscope.vue"),
);
const LogicAnalyzerView = defineAsyncComponent(
() => import("@/views/Project/LogicAnalyzer.vue"),
);
const DebuggerView = defineAsyncComponent(() => import("./Debugger.vue"));
const DDSCtrlView = defineAsyncComponent(() => import("./DDSCtrl.vue"));
const analyzer = useProvideLogicAnalyzer();
const oscilloscopeManager = useProvideOscilloscope();
const checkID = useLocalStorage("checkID", 1);
const isLoading = ref(false);
// 标签页配置
const tabs = [
{ id: 1, label: "日志终端", icon: TerminalIcon },
{ id: 2, label: "HTTP视频流", icon: VideoIcon },
{ id: 3, label: "HDMI视频流", icon: Monitor },
{ id: 4, label: "示波器", icon: SquareActivityIcon },
{ id: 5, label: "逻辑分析仪", icon: Binary },
{ id: 6, label: "嵌入式逻辑分析仪", icon: Hand },
{ id: 7, label: "信号发生器", icon: Signature },
];
// 定义事件
const emit = defineEmits<{
@@ -146,11 +224,30 @@ watch(
},
);
// 获取当前标签页标签
const getCurrentTabLabel = computed(() => {
const currentTab = tabs.find((tab) => tab.id === checkID.value);
return currentTab?.label || "";
});
function handleTabChange(event: Event) {
const target = event.currentTarget as HTMLInputElement;
if (isNull(target)) return;
checkID.value = toNumber(target.id);
const newTabId = toNumber(target.id);
// 如果不是日志终端(需要懒加载的组件),显示加载状态
if (newTabId !== 1 && newTabId !== checkID.value) {
isLoading.value = true;
// 模拟加载延迟,让用户看到加载状态
setTimeout(() => {
checkID.value = newTabId;
isLoading.value = false;
}, 300);
} else {
checkID.value = newTabId;
}
}
function toggleFullscreen() {
@@ -161,19 +258,286 @@ function toggleFullscreen() {
<style scoped lang="postcss">
@import "@/assets/main.css";
.icon {
@apply h-4 w-4 opacity-70 mr-1.5;
/* 标签栏容器 */
.tabs-container {
transition: all 0.3s ease;
}
.tabs {
@apply relative flex items-center;
@apply relative flex items-center p-1 gap-1;
background: linear-gradient(135deg, hsl(var(--b1)) 0%, hsl(var(--b2)) 100%);
backdrop-filter: blur(10px);
}
/* 标签项样式 */
.tab-item {
@apply relative flex items-center px-4 py-3 cursor-pointer rounded-lg transition-all duration-300;
@apply hover:bg-base-200;
position: relative;
min-width: 120px;
justify-content: center;
background: transparent;
border: 1px solid transparent;
}
.tab-item:hover {
background: linear-gradient(
135deg,
hsl(var(--primary) / 0.1) 0%,
hsl(var(--secondary) / 0.1) 100%
);
border-color: hsl(var(--primary) / 0.2);
transform: translateY(-1px);
box-shadow: 0 4px 12px hsl(var(--primary) / 0.15);
}
.tab-item.tab-active {
background: linear-gradient(
135deg,
hsl(var(--primary)) 0%,
hsl(var(--secondary)) 100%
);
color: hsl(var(--primary-content));
border-color: hsl(var(--primary));
transform: translateY(-2px);
box-shadow: 0 6px 20px hsl(var(--primary) / 0.3);
}
.tab-item.tab-active .icon {
@apply opacity-100;
filter: drop-shadow(0 0 4px rgba(255, 255, 255, 0.3));
}
/* 图标样式 */
.icon {
@apply h-4 w-4 opacity-70 mr-2 transition-all duration-300;
}
.tab-item:hover .icon {
@apply opacity-100;
transform: scale(1.1);
}
/* 标签文字 */
.tab-label {
@apply text-sm font-medium transition-all duration-300;
white-space: nowrap;
}
.tab-item:hover .tab-label {
font-weight: 600;
}
/* 活跃指示器 */
.active-indicator {
@apply absolute -bottom-1 left-1/2 transform -translate-x-1/2;
width: 6px;
height: 6px;
background: hsl(var(--primary-content));
border-radius: 50%;
box-shadow: 0 0 8px hsl(var(--primary-content) / 0.8);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
transform: translate(-50%, 0) scale(1);
}
50% {
opacity: 0.7;
transform: translate(-50%, 0) scale(1.2);
}
}
/* 全屏按钮容器 */
.fullscreen-container {
@apply flex items-center justify-center p-1;
}
.fullscreen-btn {
@apply flex items-center justify-center p-2 rounded-lg transition-colors;
@apply relative flex items-center justify-center p-3 rounded-lg transition-all duration-300;
@apply bg-base-200 hover:bg-primary hover:text-primary-content;
@apply border border-base-300 hover:border-primary;
@apply shadow-md hover:shadow-lg;
position: relative;
overflow: hidden;
}
.fullscreen-btn:hover {
transform: translateY(-1px) scale(1.05);
}
.fullscreen-btn .icon {
@apply mr-0;
@apply mr-0 transition-transform duration-300;
}
.fullscreen-btn:hover .icon {
transform: rotate(180deg);
}
/* 工具提示 */
.btn-tooltip {
@apply absolute -top-10 left-1/2 transform -translate-x-1/2;
@apply bg-base-content text-base-100 text-xs px-2 py-1 rounded;
@apply opacity-0 pointer-events-none transition-opacity duration-200;
white-space: nowrap;
}
.fullscreen-btn:hover .btn-tooltip {
@apply opacity-100;
}
/* 内容区域 */
.content-area {
transition: all 0.3s ease;
}
.content-wrapper {
background: linear-gradient(135deg, hsl(var(--b1)) 0%, hsl(var(--b2)) 100%);
backdrop-filter: blur(10px);
border: 1px solid hsl(var(--border) / 0.2);
position: relative;
overflow: hidden;
}
.content-wrapper::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(
90deg,
transparent,
hsl(var(--primary) / 0.3),
transparent
);
}
/* 面板内容 */
.content-panel {
@apply h-full;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 面板头部 */
.panel-content {
@apply h-full flex flex-col;
}
.panel-header {
@apply flex items-center p-4 border-b border-base-300 bg-base-100;
background: linear-gradient(135deg, hsl(var(--b1)) 0%, hsl(var(--b2)) 100%);
}
.panel-icon {
@apply h-5 w-5 mr-3 text-primary;
}
.panel-title {
@apply text-lg font-semibold text-base-content;
}
/* 终端占位符 */
.terminal-placeholder {
@apply flex-1 flex items-center justify-center;
background: linear-gradient(135deg, hsl(var(--b1)) 0%, hsl(var(--b3)) 100%);
}
.placeholder-text {
@apply text-base-content opacity-60 text-center;
font-size: 1.1rem;
}
/* 加载状态 */
.loading-container,
.loading-fallback {
@apply h-full flex items-center justify-center gap-5;
background: linear-gradient(135deg, hsl(var(--b1)) 0%, hsl(var(--b2)) 100%);
}
.loading-text {
@apply text-base-content opacity-70 text-sm;
animation: pulse 1.5s ease-in-out infinite;
}
/* 过渡动画 */
.fade-enter-active,
.fade-leave-active {
transition: all 0.3s ease;
}
.fade-enter-from {
opacity: 0;
transform: translateY(10px);
}
.fade-leave-to {
opacity: 0;
transform: translateY(-10px);
}
/* 响应式设计 */
@media (max-width: 768px) {
.tab-item {
@apply px-2 py-2;
min-width: 80px;
}
.tab-label {
@apply text-xs;
}
.icon {
@apply h-3 w-3 mr-1;
}
}
/* 深色模式适配 */
@media (prefers-color-scheme: dark) {
.content-wrapper::before {
background: linear-gradient(
90deg,
transparent,
hsl(var(--primary) / 0.5),
transparent
);
}
}
/* 辅助功能 */
.tab-item:focus-visible {
outline: 2px solid hsl(var(--primary));
outline-offset: 2px;
}
.fullscreen-btn:focus-visible {
outline: 2px solid hsl(var(--primary));
outline-offset: 2px;
}
/* 高对比度模式 */
@media (prefers-contrast: high) {
.tab-item {
border: 2px solid hsl(var(--base-content) / 0.2);
}
.tab-item.tab-active {
border: 2px solid hsl(var(--primary));
}
}
</style>

View File

@@ -0,0 +1,884 @@
<template>
<div
class="dds-controller min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 dark:from-slate-900 dark:to-blue-900 p-4"
>
<!-- 主要内容区域 -->
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
<!-- 左侧: 波形显示和自定义波形区域 (xl屏幕时占2列) -->
<div class="xl:col-span-2 space-y-6">
<!-- 波形显示区 -->
<div
class="card bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm shadow-xl border border-slate-200/50 dark:border-slate-700/50 hover:shadow-2xl transition-all duration-300"
>
<div class="card-body p-6">
<div class="flex items-center gap-3 mb-4">
<div
class="p-2 rounded-lg bg-gradient-to-r from-green-400 to-green-600"
>
<Signature class="w-6 h-6 text-white" />
</div>
<h2 class="text-2xl font-bold text-slate-800 dark:text-slate-200">
实时波形显示
</h2>
</div>
<!-- 波形显示容器 -->
<div
ref="waveformContainer"
class="relative bg-slate-900 rounded-xl p-4 border-2 border-slate-700 overflow-hidden group"
>
<div
class="absolute inset-0 bg-gradient-to-r from-blue-500/10 to-purple-500/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300"
></div>
<svg
:width="svgWidth"
:height="svgHeight"
:viewBox="`0 0 ${svgWidth} ${svgHeight}`"
class="w-full h-auto transition-all duration-500 ease-in-out"
style="min-height: 300px"
>
<!-- 背景网格 -->
<defs>
<pattern
id="grid"
width="20"
height="20"
patternUnits="userSpaceOnUse"
>
<path
d="M 20 0 L 0 0 0 20"
fill="none"
stroke="#334155"
stroke-width="0.5"
opacity="0.3"
/>
</pattern>
</defs>
<rect :width="svgWidth" :height="svgHeight" fill="url(#grid)" />
<!-- 波形路径 -->
<path
:d="currentWaveformPath"
stroke="url(#waveGradient)"
stroke-width="3"
fill="none"
class="animate-pulse"
filter="url(#glow)"
/>
<!-- 渐变定义 -->
<defs>
<linearGradient
id="waveGradient"
x1="0%"
y1="0%"
x2="100%"
y2="0%"
>
<stop
offset="0%"
style="stop-color: #10b981; stop-opacity: 1"
/>
<stop
offset="50%"
style="stop-color: #06d6a0; stop-opacity: 1"
/>
<stop
offset="100%"
style="stop-color: #0891b2; stop-opacity: 1"
/>
</linearGradient>
<filter id="glow">
<feGaussianBlur stdDeviation="2" result="coloredBlur" />
<feMerge>
<feMergeNode in="coloredBlur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<!-- 信息显示 -->
<text
x="20"
y="30"
fill="#10b981"
font-size="16"
font-weight="bold"
class="drop-shadow-sm"
>
{{ displayFrequency }}
</text>
<text
:x="svgWidth - 80"
y="30"
fill="#10b981"
font-size="16"
font-weight="bold"
class="drop-shadow-sm"
>
φ: {{ state.phase }}°
</text>
<text
:x="svgWidth / 2"
:y="svgHeight - 10"
fill="#10b981"
font-size="16"
font-weight="bold"
text-anchor="middle"
class="drop-shadow-sm"
>
{{ displayTimebase }}
</text>
</svg>
</div>
</div>
</div>
<!-- 自定义波形区域 (xl屏幕时在波形显示下方) -->
<div
class="card hidden xl:block bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm shadow-lg border border-slate-200/50 dark:border-slate-700/50"
>
<div class="card-body p-6">
<h3
class="font-bold text-xl text-slate-800 dark:text-slate-200 mb-4 flex items-center gap-3"
>
<div
class="p-2 rounded-lg bg-gradient-to-r from-purple-400 to-purple-600"
>
<CodeIcon class="w-5 h-5 text-white" />
</div>
自定义波形函数
</h3>
<div class="space-y-4">
<div class="flex items-center gap-3">
<label
class="text-sm font-medium text-slate-700 dark:text-slate-300 min-w-fit"
>函数表达式:</label
>
<input
v-model="state.customExpr"
class="input input-bordered flex-1 transition-all duration-200 focus:shadow-md focus:scale-[1.02]"
placeholder="例如: sin(t) 或 x^(2/3)+0.9*sqrt(3.3-x^2)*sin(a*PI*x) [a=7.8]"
@keyup.enter="applyCustomWaveform"
/>
<button
class="btn btn-primary font-bold hover:shadow-lg transition-all duration-300 transform hover:scale-105"
@click="applyCustomWaveform"
>
<PlayIcon class="w-4 h-4 mr-2" />
应用
</button>
</div>
<div>
<div
class="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2"
>
示例函数:
</div>
<div class="flex flex-wrap gap-2">
<button
class="btn btn-sm btn-outline btn-secondary hover:shadow-md transition-all duration-200 transform hover:scale-105"
@click="applyExampleFunction('sin(t)')"
>
正弦波
</button>
<button
class="btn btn-sm btn-outline btn-secondary hover:shadow-md transition-all duration-200 transform hover:scale-105"
@click="applyExampleFunction('sin(t)^3')"
>
立方正弦
</button>
<button
class="btn btn-sm btn-outline btn-secondary hover:shadow-md transition-all duration-200 transform hover:scale-105"
@click="
applyExampleFunction(
'((x)^(2/3)+0.9*sqrt(3.3-(x)^2)*sin(10*PI*(x)))*0.75',
)
"
>
心形函数
</button>
<button
class="btn btn-sm btn-outline btn-secondary hover:shadow-md transition-all duration-200 transform hover:scale-105"
@click="applyExampleFunction('sin(t) + 0.3*sin(3*t)')"
>
谐波叠加
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 右侧控制面板 (xl屏幕时占1列) -->
<div class="space-y-6">
<!-- 自动应用开关 -->
<div
class="card bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm shadow-lg border border-slate-200/50 dark:border-slate-700/50"
>
<div class="card-body p-4 gap-5">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<Settings class="w-5 h-5 text-blue-600" />
<span class="font-semibold text-slate-800 dark:text-slate-200"
>自动应用设置</span
>
</div>
<input
type="checkbox"
class="toggle toggle-primary scale-125"
v-model="state.autoApply"
id="auto-apply-toggle"
/>
</div>
<!-- 应用按钮 -->
<div class="text-center">
<button
class="btn btn-primary btn-lg w-full shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:scale-105"
:disabled="state.isApplying"
@click="applyWaveSettings"
>
<span v-if="state.isApplying" class="flex items-center gap-3">
<span class="loading loading-spinner loading-md"></span>
正在应用设置...
</span>
<span v-else class="flex items-center gap-3">
<Zap class="w-5 h-5" />
应用输出波形
</span>
</button>
</div>
</div>
</div>
<!-- 波形选择 -->
<div
class="card bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm shadow-lg border border-slate-200/50 dark:border-slate-700/50"
>
<div class="card-body p-4">
<h3
class="font-bold text-lg text-slate-800 dark:text-slate-200 mb-4"
>
波形类型
</h3>
<div class="grid grid-cols-3 xl:grid-cols-2 gap-2">
<div
v-for="(wave, index) in waveforms"
:key="`wave-${index}`"
:class="[
'btn transition-all duration-300 transform hover:scale-105',
state.waveformIndex === index
? 'btn-primary shadow-lg shadow-blue-500/25'
: 'btn-outline btn-primary hover:shadow-md',
]"
@click="selectWaveform(index)"
>
{{ wave.name }}
</div>
</div>
</div>
</div>
<!-- 参数控制 -->
<div
class="card bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm shadow-lg border border-slate-200/50 dark:border-slate-700/50"
>
<div class="card-body p-4">
<div class="card-title flex flex-row items-center justify-between">
<h3 class="font-bold text-lg text-slate-800 dark:text-slate-200">
信号参数
</h3>
<button
@click="resetConfiguration"
class="w-8 h-8 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="重置配置"
>
<RefreshCcw />
</button>
</div>
<!-- 时基控制 -->
<div>
<label
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2"
>时基</label
>
<div class="flex items-center gap-2">
<button
class="btn btn-sm btn-circle btn-outline btn-primary hover:shadow-md transition-all duration-200"
@click="decreaseTimebase"
>
<Minus class="w-4 h-4" />
</button>
<input
v-model="state.timebaseInput"
@blur="applyTimebaseInput"
@keyup.enter="applyTimebaseInput"
class="input input-bordered flex-1 text-center transition-all duration-200 focus:shadow-md focus:scale-105"
type="text"
/>
<button
class="btn btn-sm btn-circle btn-outline btn-primary hover:shadow-md transition-all duration-200"
@click="increaseTimebase"
>
<Plus class="w-4 h-4" />
</button>
</div>
</div>
<!-- 频率控制 -->
<div>
<label
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2"
>频率</label
>
<div class="flex items-center gap-2">
<button
class="btn btn-sm btn-circle btn-outline btn-primary hover:shadow-md transition-all duration-200"
@click="decreaseFrequency"
>
<Minus class="w-4 h-4" />
</button>
<input
v-model="state.frequencyInput"
@blur="applyFrequencyInput"
@keyup.enter="applyFrequencyInput"
class="input input-bordered flex-1 text-center transition-all duration-200 focus:shadow-md focus:scale-105"
type="text"
/>
<button
class="btn btn-sm btn-circle btn-outline btn-primary hover:shadow-md transition-all duration-200"
@click="increaseFrequency"
>
<Plus class="w-4 h-4" />
</button>
</div>
</div>
<!-- 相位控制 -->
<div>
<label
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2"
>相位</label
>
<div class="flex items-center gap-2">
<button
class="btn btn-sm btn-circle btn-outline btn-primary hover:shadow-md transition-all duration-200"
@click="decreasePhase"
>
<Minus class="w-4 h-4" />
</button>
<input
v-model="state.phaseInput"
@blur="applyPhaseInput"
@keyup.enter="applyPhaseInput"
class="input input-bordered flex-1 text-center transition-all duration-200 focus:shadow-md focus:scale-105"
type="text"
/>
<button
class="btn btn-sm btn-circle btn-outline btn-primary hover:shadow-md transition-all duration-200"
@click="increasePhase"
>
<Plus class="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
<!-- 小屏幕时自定义波形区域移到最后 -->
<div class="xl:hidden">
<div
class="card bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm shadow-lg border border-slate-200/50 dark:border-slate-700/50"
>
<div class="card-body p-6">
<h3
class="font-bold text-xl text-slate-800 dark:text-slate-200 mb-4 flex items-center gap-3"
>
<div
class="p-2 rounded-lg bg-gradient-to-r from-purple-400 to-purple-600"
>
<CodeIcon class="w-5 h-5 text-white" />
</div>
自定义波形函数
</h3>
<div class="space-y-4">
<div
class="flex flex-col sm:flex-row items-start sm:items-center gap-3"
>
<label
class="text-sm font-medium text-slate-700 dark:text-slate-300 min-w-fit"
>函数表达式:</label
>
<input
v-model="state.customExpr"
class="input input-bordered flex-1 transition-all duration-200 focus:shadow-md focus:scale-[1.02]"
placeholder="例如: sin(t) 或 x^(2/3)+0.9*sqrt(3.3-x^2)*sin(a*PI*x) [a=7.8]"
@keyup.enter="applyCustomWaveform"
/>
<button
class="btn btn-primary font-bold hover:shadow-lg transition-all duration-300 transform hover:scale-105 w-full sm:w-auto"
@click="applyCustomWaveform"
>
<PlayIcon class="w-4 h-4 mr-2" />
应用
</button>
</div>
<div>
<div
class="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2"
>
示例函数:
</div>
<div class="grid grid-cols-2 sm:flex sm:flex-wrap gap-2">
<button
class="btn btn-sm btn-outline btn-secondary hover:shadow-md transition-all duration-200 transform hover:scale-105"
@click="applyExampleFunction('sin(t)')"
>
正弦波
</button>
<button
class="btn btn-sm btn-outline btn-secondary hover:shadow-md transition-all duration-200 transform hover:scale-105"
@click="applyExampleFunction('sin(t)^3')"
>
立方正弦
</button>
<button
class="btn btn-sm btn-outline btn-secondary hover:shadow-md transition-all duration-200 transform hover:scale-105"
@click="
applyExampleFunction(
'((x)^(2/3)+0.9*sqrt(3.3-(x)^2)*sin(10*PI*(x)))*0.75',
)
"
>
心形函数
</button>
<button
class="btn btn-sm btn-outline btn-secondary hover:shadow-md transition-all duration-200 transform hover:scale-105"
@click="applyExampleFunction('sin(t) + 0.3*sin(3*t)')"
>
谐波叠加
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from "vue";
import { useEquipments } from "@/stores/equipments";
import { useDialogStore } from "@/stores/dialog";
import { toInteger } from "lodash";
import { AuthManager } from "@/utils/AuthManager";
import { DDSClient } from "@/APIClient";
import { compile, type EvalFunction } from "mathjs";
import {
Settings,
Signature,
Plus,
Minus,
Zap,
Play as PlayIcon,
Code as CodeIcon,
RefreshCcw,
} from "lucide-vue-next";
// 新增重置DDS参数
function resetConfiguration() {
state.value.frequency = 1000;
state.value.phase = 0;
state.value.timebase = 1;
state.value.waveformIndex = 0;
state.value.customExpr = "";
state.value.frequencyInput = "1.00 kHz";
state.value.phaseInput = "0";
state.value.timebaseInput = "1.00 s/div";
// 清除自定义波形
customWaveformFunction.value = null;
// 应用重置后的设置
if (state.value.autoApply) applyWaveSettings();
}
// 状态变量
const dds = AuthManager.createClient(DDSClient);
const eqps = useEquipments();
const dialog = useDialogStore();
// 响应式SVG宽高与容器引用
const waveformContainer = ref<HTMLElement | null>(null);
const svgWidth = ref(400);
const svgHeight = ref(300);
function updateSvgWidth() {
if (waveformContainer.value) {
svgWidth.value = waveformContainer.value.offsetWidth || 400;
}
}
onMounted(() => {
updateSvgWidth();
window.addEventListener("resize", updateSvgWidth);
});
onBeforeUnmount(() => {
window.removeEventListener("resize", updateSvgWidth);
});
const state = ref({
frequency: 1000,
phase: 0,
timebase: 1,
waveformIndex: 0,
customExpr: "",
isApplying: false,
frequencyInput: "1.00 kHz",
phaseInput: "0",
timebaseInput: "1.00 s/div",
autoApply: false,
});
const waveforms = [
{
name: "正弦波",
type: "sine",
fn: (x: number, width: number, height: number, phaseRad: number) =>
(height / 2) * Math.sin(2 * Math.PI * (x / width) * 2 + phaseRad),
},
{
name: "方波",
type: "square",
fn: (x: number, width: number, height: number, phaseRad: number) => {
const normX = (x / width + phaseRad / (2 * Math.PI)) % 1;
return normX < 0.5 ? height / 4 : -height / 4;
},
},
{
name: "三角波",
type: "triangle",
fn: (x: number, width: number, height: number, phaseRad: number) => {
const normX = (x / width + phaseRad / (2 * Math.PI)) % 1;
return height / 2 - height * Math.abs(2 * normX - 1);
},
},
{
name: "锯齿波",
type: "sawtooth",
fn: (x: number, width: number, height: number, phaseRad: number) => {
const normX = (x / width + phaseRad / (2 * Math.PI)) % 1;
return height / 2 - (height / 2) * (2 * normX);
},
},
{
name: "自定义",
type: "custom",
fn: (x: number, width: number, height: number, phaseRad: number) => {
if (customWaveformFunction.value) {
try {
const t = 2 * Math.PI * (x / width) * 2 + phaseRad;
// 归一化x到[-1,1],便于自定义表达式使用
const xn = (x / width) * 2 - 1;
const scope = { t, x, xn, width, height, phaseRad, PI: Math.PI };
const y = customWaveformFunction.value.evaluate(scope);
if (typeof y === "number" && isFinite(y)) {
return y * (height / 2);
}
return 0;
} catch {
return 0;
}
}
return 0;
},
},
];
// 自定义表达式函数引用mathjs EvalFunction
const customWaveformFunction = ref<EvalFunction | null>(null);
function formatFrequency(freq: number): string {
if (freq >= 1000000) {
return `${(freq / 1000000).toFixed(2)} MHz`;
} else if (freq >= 1000) {
return `${(freq / 1000).toFixed(2)} kHz`;
} else {
return `${freq.toFixed(2)} Hz`;
}
}
function parseFrequency(str: string): number {
let value = parseFloat(str);
if (str.includes("MHz")) value *= 1e6;
else if (str.includes("kHz")) value *= 1e3;
else if (str.includes("Hz")) value *= 1;
return isNaN(value) ? 1000 : value;
}
function formatTimebase(tb: number): string {
if (tb < 0.001) {
return `${(tb * 1e6).toFixed(0)} μs/div`;
} else if (tb < 1) {
return `${(tb * 1000).toFixed(0)} ms/div`;
} else {
return `${tb.toFixed(2)} s/div`;
}
}
function parseTimebase(str: string): number {
let value = parseFloat(str);
if (str.includes("μs")) value /= 1e6;
else if (str.includes("ms")) value /= 1e3;
else if (str.includes("s")) value /= 1;
return isNaN(value) ? 1 : value;
}
const displayTimebase = computed(() => formatTimebase(state.value.timebase));
const displayFrequency = computed(() => formatFrequency(state.value.frequency));
// 生成波形SVG路径
const currentWaveformPath = computed(() => {
const width = svgWidth.value;
const height = svgHeight.value - 60;
const xOffset = 0;
const yOffset = 40;
const currentWaveform = waveforms[state.value.waveformIndex];
const phaseRadians = (state.value.phase * Math.PI) / 180;
const freqLog = Math.log10(state.value.frequency) - 2;
const frequencyFactor = Math.max(0.1, Math.min(10, freqLog));
const timebaseFactor = 1 / state.value.timebase;
const scaleFactor = timebaseFactor * frequencyFactor;
let path = `M${xOffset},${yOffset + height / 2}`;
const waveFunction = currentWaveform.fn;
for (let x = 0; x <= width; x++) {
const scaledX = x * scaleFactor;
const y = waveFunction(scaledX, width, height, phaseRadians);
path += ` L${x + xOffset},${yOffset + height / 2 - y}`;
}
return path;
});
// 只允许number类型key
type NumberStateKey = "frequency" | "phase" | "timebase";
// 通用增减函数(类型安全)
function adjustValue(
key: NumberStateKey,
delta: number,
steps: number[],
min: number,
max: number,
) {
let v = state.value[key];
if (typeof v !== "number") return;
let step = steps.find((s) => Math.abs(v) < s * 10) || steps[steps.length - 1];
v += delta * step;
v = Math.max(min, Math.min(max, v));
state.value[key] = parseFloat(v.toFixed(2));
if (key === "frequency") {
state.value.frequencyInput = formatFrequency(state.value.frequency);
}
if (key === "phase") {
state.value.phaseInput = state.value.phase.toString();
}
}
function increaseTimebase() {
adjustValue("timebase", 1, [0.001, 0.01, 0.1, 0.5], 0.001, 5);
state.value.timebaseInput = formatTimebase(state.value.timebase);
if (state.value.autoApply) applyWaveSettings();
}
function decreaseTimebase() {
adjustValue("timebase", -1, [0.001, 0.01, 0.1, 0.5], 0.001, 5);
state.value.timebaseInput = formatTimebase(state.value.timebase);
if (state.value.autoApply) applyWaveSettings();
}
function applyTimebaseInput() {
const value = parseTimebase(state.value.timebaseInput);
state.value.timebase = Math.min(Math.max(value, 0.001), 5);
state.value.timebaseInput = formatTimebase(state.value.timebase);
if (state.value.autoApply) applyWaveSettings();
}
function selectWaveform(index: number) {
state.value.waveformIndex = index;
if (state.value.autoApply) applyWaveSettings();
}
function increaseFrequency() {
adjustValue("frequency", 1, [0.1, 1, 10, 100, 1000, 10000], 0.1, 10000000);
if (state.value.autoApply) applyWaveSettings();
}
function decreaseFrequency() {
adjustValue("frequency", -1, [0.1, 1, 10, 100, 1000, 10000], 0.1, 10000000);
if (state.value.autoApply) applyWaveSettings();
}
function applyFrequencyInput() {
const value = parseFrequency(state.value.frequencyInput);
state.value.frequency = Math.min(Math.max(value, 0.1), 10000000);
state.value.frequencyInput = formatFrequency(state.value.frequency);
if (state.value.autoApply) applyWaveSettings();
}
function increasePhase() {
adjustValue("phase", 15, [1], 0, 359.99);
if (state.value.phase >= 360) state.value.phase -= 360;
state.value.phaseInput = state.value.phase.toString();
if (state.value.autoApply) applyWaveSettings();
}
function decreasePhase() {
adjustValue("phase", -15, [1], 0, 359.99);
if (state.value.phase < 0) state.value.phase += 360;
state.value.phaseInput = state.value.phase.toString();
if (state.value.autoApply) applyWaveSettings();
}
function applyPhaseInput() {
let value = parseFloat(state.value.phaseInput);
if (!isNaN(value)) {
while (value >= 360) value -= 360;
while (value < 0) value += 360;
state.value.phase = value;
state.value.phaseInput = state.value.phase.toString();
if (state.value.autoApply) applyWaveSettings();
} else {
state.value.phaseInput = state.value.phase.toString();
}
}
// 自定义波形表达式
function applyCustomWaveform() {
if (!state.value.customExpr) {
customWaveformFunction.value = null;
return;
}
customWaveformFunction.value = null;
try {
const expr = state.value.customExpr;
const compiled = compile(expr);
customWaveformFunction.value = compiled;
state.value.waveformIndex = waveforms.findIndex((w) => w.type === "custom");
if (state.value.autoApply) applyWaveSettings();
} catch (e) {
dialog.error("表达式无效");
customWaveformFunction.value = null;
}
}
function applyExampleFunction(expr: string) {
state.value.customExpr = expr;
applyCustomWaveform();
}
// 应用输出(集中设备操作和错误处理)
async function applyWaveSettings() {
try {
state.value.isApplying = true;
const idx = state.value.waveformIndex;
const freq = state.value.frequency;
const ph = state.value.phase;
let ok = true;
const ret1 = await dds.setWaveNum(eqps.boardAddr, eqps.boardPort, 0, idx);
if (!ret1) ok = false;
const ret2 = await dds.setFreq(
eqps.boardAddr,
eqps.boardPort,
0,
idx,
Math.round((freq * Math.pow(2, 32 - 20)) / 10),
);
if (!ret2) ok = false;
const ret3 = await dds.setPhase(
eqps.boardAddr,
eqps.boardPort,
0,
idx,
Math.round((ph * 4096) / 360),
);
if (!ret3) ok = false;
if (!ok) dialog.error("应用失败");
} catch (e) {
dialog.error("应用失败");
console.error(e);
} finally {
state.value.isApplying = false;
}
}
</script>
<style scoped lang="postcss">
.dds-controller {
animation: fadeIn 0.6s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.card:hover {
transform: translateY(-2px);
}
.btn {
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.input:focus {
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
/* 自定义动画 */
@keyframes pulse-gentle {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.8;
}
}
.animate-pulse {
animation: pulse-gentle 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
/* 渐变文字效果 */
.bg-clip-text {
-webkit-background-clip: text;
background-clip: text;
}
/* 毛玻璃效果增强 */
.backdrop-blur-sm {
backdrop-filter: blur(8px);
}
</style>

View File

@@ -266,7 +266,12 @@
</template>
<script setup lang="ts">
import { CaptureMode, ChannelConfig, DebuggerConfig } from "@/APIClient";
import {
CaptureMode,
ChannelConfig,
DebuggerClient,
DebuggerConfig,
} from "@/APIClient";
import { useAlertStore } from "@/components/Alert";
import BaseInputField from "@/components/InputField/BaseInputField.vue";
import type { LogicDataType } from "@/components/WaveformDisplay";
@@ -421,7 +426,7 @@ async function startCapture() {
}
isCapturing.value = true;
const client = AuthManager.createAuthenticatedDebuggerClient();
const client = AuthManager.createClient(DebuggerClient);
// 构造API配置
const channelConfigs = channels.value

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@
@layout="handleVerticalSplitterResize"
>
<!-- 使用 v-show 替代 v-if -->
<SplitterPanel
<SplitterPanel
v-show="!isBottomBarFullscreen"
id="splitter-group-v-panel-project"
:default-size="verticalSplitterSize"
@@ -60,8 +60,8 @@
v-show="showDocPanel"
class="doc-panel overflow-y-auto h-full"
>
<MarkdownRenderer
:content="documentContent"
<MarkdownRenderer
:content="documentContent"
:examId="(route.query.examId as string) || ''"
/>
</div>
@@ -80,11 +80,13 @@
<!-- 功能底栏 -->
<SplitterPanel
id="splitter-group-v-panel-bar"
:default-size="isBottomBarFullscreen ? 100 : (100 - verticalSplitterSize)"
:default-size="
isBottomBarFullscreen ? 100 : 100 - verticalSplitterSize
"
:min-size="isBottomBarFullscreen ? 100 : 15"
class="w-full overflow-hidden pt-3"
>
<BottomBar
<BottomBar
:isFullscreen="isBottomBarFullscreen"
@toggle-fullscreen="handleToggleBottomBarFullscreen"
/>
@@ -106,22 +108,48 @@
/>
<!-- Navbar切换浮动按钮 -->
<div
<div
class="navbar-toggle-btn"
:class="{ 'with-navbar': navbarControl.showNavbar.value }"
>
<button
<button
@click="navbarControl.toggleNavbar"
class="btn btn-circle btn-primary shadow-lg hover:shadow-xl transition-all duration-300"
:class="{ 'btn-outline': navbarControl.showNavbar.value }"
:title="navbarControl.showNavbar.value ? '隐藏顶部导航栏' : '显示顶部导航栏'"
:title="
navbarControl.showNavbar.value ? '隐藏顶部导航栏' : '显示顶部导航栏'
"
>
<!-- 使用SVG图标表示菜单/关闭状态 -->
<svg v-if="navbarControl.showNavbar.value" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
<svg
v-if="navbarControl.showNavbar.value"
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
</div>
@@ -131,7 +159,7 @@
<script setup lang="ts">
import { ref, onMounted, watch, inject, type Ref } from "vue";
import { useRouter } from "vue-router";
import { useLocalStorage } from '@vueuse/core'; // 添加VueUse导入
import { useLocalStorage } from "@vueuse/core"; // 添加VueUse导入
import { SplitterGroup, SplitterPanel, SplitterResizeHandle } from "reka-ui";
import DiagramCanvas from "@/components/LabCanvas/DiagramCanvas.vue";
import ComponentSelector from "@/components/LabCanvas/ComponentSelector.vue";
@@ -143,7 +171,12 @@ import { useProvideComponentManager } from "@/components/LabCanvas";
import { useAlertStore } from "@/components/Alert";
import { AuthManager } from "@/utils/AuthManager";
import { useEquipments } from "@/stores/equipments";
import type { Board } from "@/APIClient";
import {
DataClient,
ResourceClient,
ResourcePurpose,
type Board,
} from "@/APIClient";
import { useRoute } from "vue-router";
const route = useRoute();
@@ -158,20 +191,29 @@ const equipments = useEquipments();
const alert = useAlertStore();
// --- Navbar控制 ---
const navbarControl = inject('navbar') as {
const navbarControl = inject("navbar") as {
showNavbar: Ref<boolean>;
toggleNavbar: () => void;
};
// --- 使用VueUse保存分栏状态 ---
// 左右分栏比例默认60%
const horizontalSplitterSize = useLocalStorage('project-horizontal-splitter-size', 60);
const horizontalSplitterSize = useLocalStorage(
"project-horizontal-splitter-size",
60,
);
// 上下分栏比例默认80%
const verticalSplitterSize = useLocalStorage('project-vertical-splitter-size', 80);
const verticalSplitterSize = useLocalStorage(
"project-vertical-splitter-size",
80,
);
// 底栏全屏状态
const isBottomBarFullscreen = useLocalStorage('project-bottom-bar-fullscreen', false);
const isBottomBarFullscreen = useLocalStorage(
"project-bottom-bar-fullscreen",
false,
);
// 文档面板显示状态
const showDocPanel = useLocalStorage('project-show-doc-panel', false);
const showDocPanel = useLocalStorage("project-show-doc-panel", false);
function handleToggleBottomBarFullscreen() {
isBottomBarFullscreen.value = !isBottomBarFullscreen.value;
@@ -216,25 +258,29 @@ async function loadDocumentContent() {
const examId = route.query.examId as string;
if (examId) {
// 如果有实验ID从API加载实验文档
console.log('加载实验文档:', examId);
const client = AuthManager.createAuthenticatedResourceClient();
console.log("加载实验文档:", examId);
const client = AuthManager.createClient(ResourceClient);
// 获取markdown类型的模板资源列表
const resources = await client.getResourceList(examId, 'doc', 'template');
const resources = await client.getResourceList(
examId,
"doc",
ResourcePurpose.Template,
);
if (resources && resources.length > 0) {
// 获取第一个markdown资源
const markdownResource = resources[0];
// 使用新的ResourceClient API获取资源文件内容
const response = await client.getResourceById(markdownResource.id);
if (!response || !response.data) {
throw new Error('获取markdown文件失败');
throw new Error("获取markdown文件失败");
}
const content = await response.data.text();
// 更新文档内容暂时不处理图片路径由MarkdownRenderer处理
documentContent.value = content;
} else {
@@ -279,17 +325,17 @@ function updateComponentDirectProp(
// 检查并初始化用户实验板
async function checkAndInitializeBoard() {
try {
const client = AuthManager.createAuthenticatedDataClient();
const client = AuthManager.createClient(DataClient);
const userInfo = await client.getUserInfo();
if (userInfo.boardID && userInfo.boardID.trim() !== '') {
if (userInfo.boardID && userInfo.boardID.trim() !== "") {
// 用户已绑定实验板获取实验板信息并更新到equipment
try {
const board = await client.getBoardByID(userInfo.boardID);
updateEquipmentFromBoard(board);
alert?.show(`实验板 ${board.boardName} 已连接`, "success");
} catch (boardError) {
console.error('获取实验板信息失败:', boardError);
console.error("获取实验板信息失败:", boardError);
alert?.show("获取实验板信息失败", "error");
showRequestBoardDialog.value = true;
}
@@ -298,7 +344,7 @@ async function checkAndInitializeBoard() {
showRequestBoardDialog.value = true;
}
} catch (error) {
console.error('检查用户实验板失败:', error);
console.error("检查用户实验板失败:", error);
alert?.show("检查用户信息失败", "error");
showRequestBoardDialog.value = true;
}
@@ -308,12 +354,12 @@ async function checkAndInitializeBoard() {
function updateEquipmentFromBoard(board: Board) {
equipments.boardAddr = board.ipAddr;
equipments.boardPort = board.port;
console.log(`实验板信息已更新到equipment store:`, {
address: board.ipAddr,
port: board.port,
boardName: board.boardName,
boardId: board.id
boardId: board.id,
});
}
@@ -321,7 +367,7 @@ function updateEquipmentFromBoard(board: Board) {
function handleRequestBoardClose() {
showRequestBoardDialog.value = false;
// 如果用户取消申请,可以选择返回上一页或显示警告
router.push('/');
router.push("/");
}
// 处理申请实验板成功
@@ -338,12 +384,12 @@ onMounted(async () => {
const isAuthenticated = await AuthManager.isAuthenticated();
if (!isAuthenticated) {
// 验证失败,跳转到登录页面
router.push('/login');
router.push("/login");
return;
}
} catch (error) {
console.error('身份验证失败:', error);
router.push('/login');
console.error("身份验证失败:", error);
router.push("/login");
return;
}

View File

@@ -1,109 +1,332 @@
<template>
<div class="bg-base-100 flex flex-col gap-4">
<!-- 波形展示 -->
<div class="card bg-base-200 shadow-xl mx-5">
<div class="card-body">
<h2 class="card-title flex flex-row justify-between">
<div class="flex items-center gap-2">
<Activity class="w-5 h-5" />
波形显示
</div>
<div class="flex items-center gap-2">
<button class="btn btn-sm btn-warning" @click="osc.stopCapture" :disabled="!osc.isCapturing.value">
停止捕获
</button>
<div class="flex items-center gap-2">
<button class="btn btn-sm btn-error" @click="osc.clearOscilloscopeData">
清空
<div
class="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 dark:from-slate-900 dark:to-slate-800 p-4"
>
<!-- 顶部状态栏 -->
<div class="status-bar mb-6">
<div
class="card bg-white/80 dark:bg-slate-800/80 backdrop-blur-lg shadow-xl border border-white/20"
>
<div class="card-body p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="status-indicator flex items-center gap-2">
<div class="relative">
<Activity class="w-6 h-6 text-blue-600" />
<div
v-if="osc.isCapturing.value"
class="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full animate-pulse"
></div>
</div>
<div>
<h1
class="text-xl font-bold text-slate-800 dark:text-slate-200"
>
数字示波器
</h1>
<p class="text-sm text-slate-600 dark:text-slate-400">
{{ osc.isCapturing.value ? "正在采集数据..." : "待机状态" }}
</p>
</div>
</div>
</div>
<div class="control-buttons flex items-center gap-3">
<button
class="btn-gradient"
:class="osc.isCapturing.value ? 'btn-stop' : 'btn-start'"
@click="toggleCapture"
>
<component
:is="osc.isCapturing.value ? Square : Play"
class="w-5 h-5"
/>
{{ osc.isCapturing.value ? "停止采集" : "开始采集" }}
</button>
<button
class="btn-clear"
@click="osc.clearOscilloscopeData"
:disabled="osc.isCapturing.value"
>
<Trash2 class="w-4 h-4" />
清空数据
</button>
</div>
</div>
</h2>
<OscilloscopeWaveformDisplay />
</div>
</div>
</div>
<!-- 示波器配置 -->
<div class="card bg-base-200 shadow-xl mx-5">
<div class="card-body">
<h2 class="card-title">示波器配置</h2>
<form class="flex flex-col gap-2" @submit.prevent="applyConfiguration">
<div class="flex flex-row items-center justify-between gap-4">
<label>
边沿触发:
<select v-model="osc.config.triggerRisingEdge" class="select select-bordered w-24">
<option :value="true">上升沿</option>
<option :value="false">下降沿</option>
</select>
</label>
<label>
触发电平:
<div class="flex items-center gap-2">
<input type="range" min="0" max="255" step="1" v-model="osc.config.triggerLevel"
class="range range-sm w-50" />
<input type="number" v-model="osc.config.triggerLevel" min="0" max="255"
class="input input-bordered w-24" />
<!-- 主要内容区域 -->
<div class="main-content grid grid-cols-1 xl:grid-cols-4 gap-6">
<!-- 波形显示区域 - 占据大部分空间 -->
<div class="waveform-section xl:col-span-3">
<div
class="card bg-white/80 dark:bg-slate-800/80 backdrop-blur-lg shadow-xl border border-white/20 h-full"
>
<div class="card-body p-6">
<div class="waveform-header flex items-center justify-between mb-4">
<h2
class="text-lg font-semibold text-slate-800 dark:text-slate-200 flex items-center gap-2"
>
<Zap class="w-5 h-5 text-yellow-500" />
波形显示
</h2>
<div class="waveform-controls flex items-center gap-2">
<div
class="refresh-indicator flex items-center gap-2 text-sm text-slate-600 dark:text-slate-400"
>
<div
class="w-2 h-2 bg-green-500 rounded-full animate-pulse"
></div>
{{ osc.config.captureFrequency }}Hz 刷新频率
</div>
</div>
</label>
<label>
水平偏移:
<div class="flex items-center gap-2">
<input type="range" min="0" max="1000" step="1" v-model="osc.config.horizontalShift"
class="range range-sm w-50" />
<input type="number" v-model="osc.config.horizontalShift" min="0" max="1000"
class="input input-bordered w-24" />
</div>
<div
class="waveform-display h-full relative overflow-hidden rounded-lg border border-slate-200 dark:border-slate-700"
>
<OscilloscopeWaveformDisplay class="w-full h-full" />
<!-- 数据覆盖层 -->
<div
v-if="osc.isCapturing.value && !hasWaveformData"
class="absolute inset-0 flex items-center justify-center bg-slate-50/50 dark:bg-slate-900/50 backdrop-blur-sm"
>
<div class="text-center space-y-4">
<div class="w-16 h-16 mx-auto text-slate-400">
<Activity class="w-full h-full" />
</div>
<p class="text-slate-600 dark:text-slate-400">
等待波形数据...
</p>
</div>
</div>
</label>
<label>
抽取率:
<div class="flex items-center gap-2">
<input type="range" min="0" max="100" step="1" v-model="osc.config.decimationRate"
class="range range-sm w-50" />
<input type="number" v-model="osc.config.decimationRate" min="0" max="100"
class="input input-bordered w-24" />
</div>
</label>
</div>
<div class="flex gap-4">
</div>
<div class="flex items-center justify-between gap-2 mt-2">
<label>
刷新间隔(ms):
<div class="flex items-center gap-2">
<input type="range" min="100" max="3000" step="100" v-model="osc.refreshIntervalMs.value"
class="range range-sm w-50" />
<input type="number" min="100" max="3000" step="100" v-model="osc.refreshIntervalMs.value"
class="input input-bordered w-24" />
</div>
</label>
<div class="flex items-center gap-2">
<button class="btn btn-primary" type="submit" :disabled="osc.isApplying.value || osc.isCapturing.value">
应用配置
</button>
<button class="btn btn-secondary" type="button" @click="osc.resetConfiguration"
:disabled="osc.isApplying.value || osc.isCapturing.value">
重置
</button>
<button class="btn btn-outline" @click="osc.refreshRAM" :disabled="osc.isApplying.value || osc.isCapturing.value">
刷新RAM
</button>
<!-- <button class="btn btn-accent" @click="osc.generateTestData" :disabled="osc.isOperationInProgress.value">
生成测试数据
</button> -->
</div>
</div>
</form>
</div>
</div>
<!-- 控制面板 -->
<div class="control-panel xl:col-span-1">
<div class="space-y-6">
<!-- 触发设置 -->
<div
class="card bg-white/80 dark:bg-slate-800/80 backdrop-blur-lg shadow-xl border border-white/20"
>
<div class="card-body p-4">
<h3
class="text-lg font-semibold text-slate-800 dark:text-slate-200 flex items-center gap-2 mb-4"
>
<Target class="w-5 h-5 text-red-500" />
触发设置
</h3>
<div class="space-y-4">
<div class="form-control">
<label class="label">
<span class="label-text font-medium">触发边沿</span>
</label>
<select
v-model="osc.config.triggerRisingEdge"
class="select select-bordered w-full focus:border-blue-500 transition-colors"
>
<option :value="true">上升沿 </option>
<option :value="false">下降沿 </option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">触发电平</span>
<span class="label-text-alt">{{ triggerLevel }}</span>
</label>
<input
type="range"
min="0"
max="255"
step="1"
v-model="triggerLevel"
class="range range-primary [--range-bg:#2b7fff]"
/>
<div
class="range-labels flex justify-between text-xs text-slate-500 mt-1 mx-2"
>
<span>&ensp;0&ensp;</span>
<span>128</span>
<span>255</span>
</div>
</div>
</div>
</div>
</div>
<!-- 时基设置 -->
<div
class="card bg-white/80 dark:bg-slate-800/80 backdrop-blur-lg shadow-xl border border-white/20"
>
<div class="card-body p-4">
<h3
class="text-lg font-semibold text-slate-800 dark:text-slate-200 flex items-center gap-2 mb-4"
>
<Clock class="w-5 h-5 text-blue-500" />
时基控制
</h3>
<div class="space-y-4">
<div class="form-control">
<label class="label">
<span class="label-text font-medium">水平偏移</span>
<span class="label-text-alt">{{ horizontalShift }}</span>
</label>
<input
type="range"
min="0"
max="1000"
step="1"
v-model="horizontalShift"
class="range range-secondary [--range-bg:#c27aff]"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">抽取率</span>
<span class="label-text-alt">{{ decimationRate }}%</span>
</label>
<input
type="range"
min="0"
max="100"
step="1"
v-model="decimationRate"
class="range range-accent [--range-bg:#fb64b6]"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">刷新频率</span>
<span class="label-text-alt">{{ captureFrequency }}Hz</span>
</label>
<input
type="range"
min="1"
max="1000"
step="1"
v-model="captureFrequency"
class="range range-info [--range-bg:#51a2ff]"
/>
</div>
</div>
</div>
</div>
<!-- 系统控制 -->
<div
class="card bg-white/80 dark:bg-slate-800/80 backdrop-blur-lg shadow-xl border border-white/20"
>
<div class="card-body p-4">
<div
class="card-title flex flex-row justify-between items-center mb-4"
>
<h3
class="text-lg font-semibold text-slate-800 dark:text-slate-200 flex items-center gap-2"
>
<Settings class="w-5 h-5 text-purple-500" />
系统控制
</h3>
<!-- 自动应用开关 -->
<div class="form-control">
<label class="label cursor-pointer">
<span class="label-text text-sm font-medium"
>自动应用设置</span
>
<input
type="checkbox"
class="toggle toggle-primary"
v-model="osc.isAutoApplying"
/>
</label>
</div>
</div>
<div class="space-y-4">
<!-- 控制按钮组 -->
<div class="space-y-2">
<button
class="btn-primary-full"
@click="applyConfiguration"
:disabled="osc.isApplying.value || osc.isCapturing.value"
>
<CheckCircle class="w-4 h-4" />
应用配置
</button>
<button
class="btn-secondary-full"
@click="resetConfiguration"
:disabled="osc.isApplying.value || osc.isCapturing.value"
>
<RotateCcw class="w-4 h-4" />
重置配置
</button>
<button
class="btn-outline-full"
@click="osc.refreshRAM"
:disabled="osc.isApplying.value || osc.isCapturing.value"
>
<RefreshCw class="w-4 h-4" />
刷新RAM
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 状态提示 -->
<div
v-if="osc.isApplying.value"
class="fixed bottom-4 right-4 alert alert-info shadow-lg max-w-sm animate-slide-in-right"
>
<div class="flex items-center gap-2">
<div
class="animate-spin w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full"
></div>
<span>正在应用配置...</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Activity } from "lucide-vue-next";
import {
Activity,
Settings,
Play,
Square,
Trash2,
Zap,
Target,
Clock,
CheckCircle,
RotateCcw,
RefreshCw,
} from "lucide-vue-next";
import { OscilloscopeWaveformDisplay } from "@/components/Oscilloscope";
import { useEquipments } from "@/stores/equipments";
import { useOscilloscopeState } from "@/components/Oscilloscope/OscilloscopeManager";
import { useRequiredInjection } from "@/utils/Common";
import { ref, computed } from "vue";
import { watchEffect } from "vue";
import { toNumber } from "lodash";
// 使用全局设备配置
const equipments = useEquipments();
@@ -111,6 +334,196 @@ const equipments = useEquipments();
// 获取示波器状态和操作
const osc = useRequiredInjection(useOscilloscopeState);
const decimationRate = ref(osc.config.decimationRate);
watchEffect(() => {
osc.config.decimationRate = toNumber(decimationRate.value);
});
const captureFrequency = ref(osc.config.captureFrequency);
watchEffect(() => {
osc.config.captureFrequency = toNumber(captureFrequency.value);
});
const triggerLevel = ref(osc.config.triggerLevel);
watchEffect(() => {
osc.config.triggerLevel = toNumber(triggerLevel.value);
});
const horizontalShift = ref(osc.config.horizontalShift);
watchEffect(() => {
osc.config.horizontalShift = toNumber(horizontalShift.value);
});
// 计算是否有波形数据
const hasWaveformData = computed(() => {
const data = osc.oscData.value;
return data && data.x && data.y && data.x.length > 0;
});
// 应用配置
const applyConfiguration = () => osc.applyConfiguration();
function applyConfiguration() {
osc.applyConfiguration();
}
function toggleCapture() {
osc.toggleCapture();
}
function resetConfiguration() {
osc.resetConfiguration();
horizontalShift.value = osc.config.horizontalShift;
triggerLevel.value = osc.config.triggerLevel;
captureFrequency.value = osc.config.captureFrequency;
decimationRate.value = osc.config.decimationRate;
}
</script>
<style scoped lang="postcss">
@import "@/assets/main.css";
/* 渐变按钮样式 */
.btn-gradient {
@apply px-6 py-3 rounded-lg font-medium transition-all duration-300 transform hover:scale-105 active:scale-95 shadow-lg flex items-center gap-2;
}
.btn-start {
@apply bg-gradient-to-r from-green-500 to-blue-600 hover:from-green-600 hover:to-blue-700 text-white shadow-green-200 hover:shadow-green-300;
}
.btn-stop {
@apply bg-gradient-to-r from-red-500 to-pink-600 hover:from-red-600 hover:to-pink-700 text-white shadow-red-200 hover:shadow-red-300;
}
.btn-clear {
@apply px-4 py-3 bg-gradient-to-r from-orange-500 to-red-500 hover:from-orange-600 hover:to-red-600 text-white rounded-lg font-medium transition-all duration-300 transform hover:scale-105 active:scale-95 shadow-lg flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none;
}
/* 全宽按钮样式 */
.btn-primary-full {
@apply w-full px-4 py-3 bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white rounded-lg font-medium transition-all duration-300 transform hover:scale-[1.02] active:scale-[0.98] shadow-lg flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none;
}
.btn-secondary-full {
@apply w-full px-4 py-3 bg-gradient-to-r from-gray-500 to-gray-600 hover:from-gray-600 hover:to-gray-700 text-white rounded-lg font-medium transition-all duration-300 transform hover:scale-[1.02] active:scale-[0.98] shadow-lg flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none;
}
.btn-outline-full {
@apply w-full px-4 py-3 border-2 border-slate-300 dark:border-slate-600 hover:border-blue-500 dark:hover:border-blue-400 text-slate-700 dark:text-slate-300 hover:text-blue-600 dark:hover:text-blue-400 rounded-lg font-medium transition-all duration-300 transform hover:scale-[1.02] active:scale-[0.98] shadow-sm hover:shadow-md flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none;
}
/* 滑块样式美化 */
.range {
@apply rounded-lg appearance-none cursor-pointer w-full px-2;
--range-fill: 0;
--range-thumb: white;
}
.range::-webkit-slider-thumb {
@apply appearance-none bg-white border-2 border-current rounded-full cursor-pointer shadow-lg hover:shadow-xl transition-shadow duration-200;
}
.range::-moz-range-thumb {
@apply bg-white border-2 border-current rounded-full cursor-pointer shadow-lg hover:shadow-xl transition-shadow duration-200;
}
/* 范围标签 */
.range-labels {
margin-top: 0.25rem;
}
/* 卡片悬停效果 */
.card {
@apply transition-all duration-300 hover:shadow-2xl hover:scale-[1.01];
}
/* 自定义动画 */
@keyframes slide-in-right {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.animate-slide-in-right {
animation: slide-in-right 0.3s ease-out;
}
/* 玻璃态效果增强 */
.backdrop-blur-lg {
backdrop-filter: blur(16px);
}
/* 状态指示器脉动效果 */
@keyframes pulse-glow {
0%,
100% {
box-shadow: 0 0 5px currentColor;
}
50% {
box-shadow:
0 0 20px currentColor,
0 0 30px currentColor;
}
}
.status-indicator .animate-pulse {
animation: pulse-glow 2s infinite;
}
/* 响应式调整 */
@media (max-width: 1280px) {
.main-content {
@apply grid-cols-1;
}
.control-panel {
@apply order-first;
}
.control-panel .space-y-6 {
@apply grid grid-cols-1 md:grid-cols-3 gap-4 space-y-0;
}
}
@media (max-width: 768px) {
.control-panel .space-y-6 {
@apply grid-cols-1 space-y-4;
}
.control-buttons {
@apply flex-col gap-2;
}
.status-bar .card-body {
@apply flex-col items-start gap-4;
}
}
/* 滚动条美化 */
::-webkit-scrollbar {
@apply w-2;
}
::-webkit-scrollbar-track {
@apply bg-slate-100 dark:bg-slate-800 rounded-full;
}
::-webkit-scrollbar-thumb {
@apply bg-slate-300 dark:bg-slate-600 rounded-full hover:bg-slate-400 dark:hover:bg-slate-500;
}
/* 输入焦点效果 */
.select:focus,
.input:focus {
@apply ring-2 ring-blue-500 opacity-50 border-blue-500;
}
/* 切换开关样式 */
.toggle {
@apply transition-all duration-300;
}
.toggle:checked {
@apply shadow-lg;
}
</style>

View File

@@ -75,7 +75,7 @@ import { ref, watch } from "vue";
import { CheckCircle } from "lucide-vue-next";
import { AuthManager } from "@/utils/AuthManager";
import { useAlertStore } from "@/components/Alert";
import type { Board } from "@/APIClient";
import { DataClient, type Board } from "@/APIClient";
interface Props {
open: boolean;
@@ -113,7 +113,7 @@ async function checkUserBoard() {
boardInfo.value = null;
try {
const client = AuthManager.createAuthenticatedDataClient();
const client = AuthManager.createClient(DataClient);
const userInfo = await client.getUserInfo();
if (userInfo.boardID && userInfo.boardID.trim() !== "") {
@@ -140,7 +140,7 @@ async function requestBoard() {
requesting.value = true;
try {
const client = AuthManager.createAuthenticatedDataClient();
const client = AuthManager.createClient(DataClient);
const board = await client.getAvailableBoard(undefined);
if (board) {

View File

@@ -387,8 +387,6 @@ import { VideoStreamClient, ResolutionConfigRequest } from "@/APIClient";
import { useEquipments } from "@/stores/equipments";
import { AuthManager } from "@/utils/AuthManager";
const eqps = useEquipments();
// 状态管理
const loading = ref(false);
const configing = ref(false);
@@ -433,7 +431,7 @@ const currentVideoSource = ref("");
const logs = ref<Array<{ time: Date; level: string; message: string }>>([]);
// API 客户端
const videoClient = AuthManager.createAuthenticatedVideoStreamClient();
const videoClient = AuthManager.createClient(VideoStreamClient);
// 添加日志
const addLog = (level: string, message: string) => {
@@ -510,7 +508,7 @@ const toggleStreamType = async () => {
"success",
`已切换到${streamType.value === "usbCamera" ? "USB摄像头" : "视频流"}`,
);
stopStream();
await stopStream();
} catch (error) {
addLog("error", `切换视频流类型失败: ${error}`);
console.error("切换视频流类型失败:", error);
@@ -647,7 +645,8 @@ const tryReconnect = () => {
// 执行对焦
const performFocus = async () => {
if (isFocusing.value || !isPlaying.value) return;
if (isFocusing.value || !isPlaying.value || streamType.value === "usbCamera")
return;
try {
isFocusing.value = true;
@@ -711,7 +710,7 @@ const startStream = async () => {
try {
addLog("info", "正在启动视频流...");
videoStatus.value = "正在连接视频流...";
videoClient.setVideoStreamEnable(true);
await videoClient.setVideoStreamEnable(true);
// 刷新状态
await refreshStatus();
@@ -778,7 +777,7 @@ const changeResolution = async () => {
// 如果正在播放,先停止视频流
if (wasPlaying) {
stopStream();
await stopStream();
await new Promise((resolve) => setTimeout(resolve, 1000)); // 等待1秒
}
@@ -815,10 +814,10 @@ const changeResolution = async () => {
};
// 停止视频流
const stopStream = () => {
const stopStream = async () => {
try {
addLog("info", "正在停止视频流...");
videoClient.setVideoStreamEnable(false);
await videoClient.setVideoStreamEnable(false);
// 清除视频源
currentVideoSource.value = "";

View File

@@ -174,7 +174,12 @@
import { ref, reactive, watch } from "vue";
import { AuthManager } from "../../utils/AuthManager";
import { useAlertStore } from "../../components/Alert";
import { BoardStatus, type NetworkConfigDto } from "../../APIClient";
import {
BoardStatus,
DataClient,
NetConfigClient,
type NetworkConfigDto,
} from "../../APIClient";
import { useRequiredInjection } from "@/utils/Common";
import { useBoardManager } from "@/utils/BoardManager";
@@ -267,8 +272,7 @@ async function handleSubmit() {
isSubmitting.value = true;
try {
// 通过 AuthManager 获取认证的 DataClient
const dataClient = AuthManager.createAuthenticatedDataClient();
const dataClient = AuthManager.createClient(DataClient);
// 添加板卡到数据库
const boardId = await dataClient.addBoard(form.name.trim());
@@ -293,8 +297,7 @@ async function handleCancelPairing() {
if (!addedBoardId.value) return;
try {
// 通过 AuthManager 获取认证的 DataClient
const dataClient = AuthManager.createAuthenticatedDataClient();
const dataClient = AuthManager.createClient(DataClient);
// 删除添加的板卡
await dataClient.deleteBoard(addedBoardId.value);
@@ -317,8 +320,8 @@ async function handlePairingConfirm() {
try {
// 通过 AuthManager 获取认证的客户端
const dataClient = AuthManager.createAuthenticatedDataClient();
const netConfigClient = AuthManager.createAuthenticatedNetConfigClient();
const dataClient = AuthManager.createClient(DataClient);
const netConfigClient = AuthManager.createClient(NetConfigClient);
// 获取数据库中对应分配的板卡信息
const boardInfo = await dataClient.getBoardByID(addedBoardId.value);
@@ -365,7 +368,7 @@ async function handlePairingConfirm() {
// 配置失败,删除数据库中的板卡信息
try {
const dataClient = AuthManager.createAuthenticatedDataClient();
const dataClient = AuthManager.createClient(DataClient);
await dataClient.deleteBoard(addedBoardId.value);
} catch (deleteError) {
console.error("删除板卡失败:", deleteError);

View File

@@ -2,18 +2,19 @@
<div class="flex flex-row justify-between items-center">
<h1 class="text-3xl font-bold mb-6">FPGA 设备管理</h1>
<div>
<button
class="btn btn-ghost group"
@click="tableManager.getAllBoards"
>
<RefreshCw class="w-4 h-4 mr-2 transition-transform duration-300 group-hover:rotate-180" />
<button class="btn btn-ghost group" @click="tableManager.getAllBoards">
<RefreshCw
class="w-4 h-4 mr-2 transition-transform duration-300 group-hover:rotate-180"
/>
刷新
</button>
<button
class="btn btn-ghost text-error hover:underline group"
@click="tableManager.toggleEditMode"
>
<Edit class="w-4 h-4 mr-2 transition-transform duration-200 group-hover:scale-110" />
<Edit
class="w-4 h-4 mr-2 transition-transform duration-200 group-hover:scale-110"
/>
编辑
</button>
</div>
@@ -25,14 +26,14 @@
<div class="flex items-center my-2 gap-4">
<input
type="text"
placeholder="筛选 IP 地址..."
placeholder="筛选名称..."
class="input input-bordered max-w-sm"
:value="
tableManager.getColumnByKey('devAddr')?.getFilterValue() as string
tableManager.getColumnByKey('boardName')?.getFilterValue() as string
"
@input="
tableManager
.getColumnByKey('devAddr')
.getColumnByKey('boardName')
?.setFilterValue(($event.target as HTMLInputElement).value)
"
/>
@@ -85,7 +86,9 @@
:disabled="!tableManager.isEditMode.value"
@click="showAddBoardDialog = true"
>
<Plus class="w-4 h-4 mr-2 transition-transform duration-200 group-hover:scale-110" />
<Plus
class="w-4 h-4 mr-2 transition-transform duration-200 group-hover:scale-110"
/>
新增实验板
</button>
@@ -94,7 +97,9 @@
:disabled="!tableManager.isEditMode.value"
@click="tableManager.deleteSelectedBoards"
>
<Trash2 class="w-4 h-4 mr-2 transition-transform duration-200 group-hover:scale-110 group-hover:animate-pulse" />
<Trash2
class="w-4 h-4 mr-2 transition-transform duration-200 group-hover:scale-110 group-hover:animate-pulse"
/>
删除选中
</button>
</div>

View File

@@ -35,6 +35,11 @@ import { toNumber } from "lodash";
import { onMounted, ref } from "vue";
import { AuthManager } from "@/utils/AuthManager";
import UserInfo from "./UserInfo.vue";
import { useRoute, useRouter } from "vue-router";
import { isArray } from "@vue/shared";
const route = useRoute();
const router = useRouter();
const activePage = ref(1);
const isAdmin = ref(false);
@@ -42,15 +47,37 @@ const isAdmin = ref(false);
function setActivePage(event: Event) {
const target = event.currentTarget as HTMLLinkElement;
const newPage = toNumber(target.id);
if (newPage === activePage.value) return;
// 如果用户不是管理员但试图访问管理员页面,则忽略
if (newPage === 100 && !isAdmin.value) {
return;
}
if (newPage == 1) {
router.push({ path: "/user/info" });
} else if (newPage == 2) {
router.push({ path: "/user/admin/users" });
} else if (newPage == 100) {
router.push({ path: "/user/admin/boards" });
}
activePage.value = newPage;
}
onMounted(() => {
const page = route.params.page;
if (page === "info") {
activePage.value = 1;
} else if (isArray(page) && page[0] === "admin" && page[1] === "users") {
activePage.value = 2;
} else if (isArray(page) && page[0] === "admin" && page[1] === "boards") {
activePage.value = 100;
} else {
router.push({ path: "/user/info" });
}
});
onMounted(async () => {
try {
// 首先验证用户是否已登录
@@ -60,16 +87,16 @@ onMounted(async () => {
// 这里可以使用路由跳转
return;
}
// 验证管理员权限
isAdmin.value = await AuthManager.verifyAdminAuth();
isAdmin.value = await AuthManager.isAdminAuthenticated();
// 如果当前页面是管理员页面但用户不是管理员,切换到用户信息页面
if (activePage.value === 100 && !isAdmin.value) {
activePage.value = 1;
}
} catch (error) {
console.error('用户认证检查失败:', error);
console.error("用户认证检查失败:", error);
// 可以在这里处理错误,比如显示错误信息或重定向到登录页面
}
});

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