29 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
70 changed files with 12833 additions and 1877 deletions

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

@@ -87,7 +87,9 @@ try
if (!string.IsNullOrEmpty(accessToken) && (
path.StartsWithSegments("/hubs/JtagHub") ||
path.StartsWithSegments("/hubs/ProgressHub") ||
path.StartsWithSegments("/hubs/DigitalTubesHub")
path.StartsWithSegments("/hubs/DigitalTubesHub") ||
path.StartsWithSegments("/hubs/RotaryEncoderHub") ||
path.StartsWithSegments("/hubs/OscilloscopeHub")
))
{
// Read the token out of the query string
@@ -128,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()
@@ -254,6 +256,8 @@ try
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();

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,8 +31,7 @@
<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="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">

View File

@@ -258,12 +258,12 @@ public class Image
/// <summary>
/// 将原始 JPEG 数据补全 JPEG 头部,生成完整的 JPEG 图片
/// </summary>
/// <param name="jpegData">原始 JPEG 数据(可能缺少完整头部</param>
/// <param name="jpegData">原始 JPEG 扫描数据(不含头尾</param>
/// <param name="width">图像宽度</param>
/// <param name="height">图像高度</param>
/// <param name="quality">JPEG质量1-100默认80</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, int quality = 80)
public static Result<byte[]> CompleteJpegData(byte[] jpegData, int width, int height, uint[] quantizationTable)
{
if (jpegData == null)
return new(new ArgumentNullException(nameof(jpegData)));
@@ -271,95 +271,148 @@ public class Image
if (width <= 0 || height <= 0)
return new(new ArgumentException("Width and height must be positive"));
if (quality < 1 || quality > 100)
return new(new ArgumentException("Quality must be between 1 and 100"));
if (quantizationTable == null || quantizationTable.Length != 192)
return new(new ArgumentException("Quantization table must contain exactly 192 values (64 Y + 64 Cb + 64 Cr)"));
try
{
// 检查是否已经是完整的 JPEG 文件(以 FFD8 开头FFD9 结尾)
if (jpegData.Length >= 4 &&
jpegData[0] == 0xFF && jpegData[1] == 0xD8 &&
jpegData[jpegData.Length - 2] == 0xFF && jpegData[jpegData.Length - 1] == 0xD9)
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++)
{
// 已经是完整的 JPEG 文件,直接返回
return jpegData;
jpegBytes.Add((byte)Math.Min(255, quantizationTable[i]));
}
// 创建一个临时的 RGB24 图像用于生成 JPEG 头部
using var tempImage = new SixLabors.ImageSharp.Image<Rgb24>(new Configuration
// 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])
{
}, width, height);
// 填充临时图像(使用简单的渐变色作为占位符)
for (int y = 0; y < height; y++)
{
for (int x = 0; x < width; x++)
{
tempImage[x, y] = new Rgb24((byte)(x % 256), (byte)(y % 256), 128);
}
jpegBytes.Add((byte)Math.Min(255, quantizationTable[i]));
}
using var stream = new MemoryStream();
tempImage.SaveAsJpeg(stream, new JpegEncoder { Quality = quality });
var completeJpeg = stream.ToArray();
// 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
});
// 如果原始数据看起来是 JPEG 扫描数据,尝试替换扫描数据部分
if (jpegData.Length > 0)
{
// 查找 JPEG 扫描数据开始位置SOS 标记 0xFFDA 后)
int sosIndex = -1;
for (int i = 0; i < completeJpeg.Length - 1; i++)
{
if (completeJpeg[i] == 0xFF && completeJpeg[i + 1] == 0xDA)
{
// 跳过 SOS 段头部,找到实际扫描数据开始位置
i += 2; // 跳过 FF DA
if (i < completeJpeg.Length - 1)
{
int segmentLength = (completeJpeg[i] << 8) | completeJpeg[i + 1];
sosIndex = i + segmentLength;
break;
}
}
}
// 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
});
// 查找 EOI 标记位置0xFFD9
int eoiIndex = -1;
for (int i = completeJpeg.Length - 2; i >= 0; i--)
{
if (completeJpeg[i] == 0xFF && completeJpeg[i + 1] == 0xD9)
{
eoiIndex = i;
break;
}
}
// 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
});
if (sosIndex > 0 && eoiIndex > sosIndex)
{
// 替换扫描数据部分
var headerLength = sosIndex;
var footerStart = eoiIndex;
var footerLength = completeJpeg.Length - footerStart;
// 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
});
var newJpegLength = headerLength + jpegData.Length + footerLength;
var newJpegData = new byte[newJpegLength];
// 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
});
// 复制头部
Array.Copy(completeJpeg, 0, newJpegData, 0, headerLength);
// 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
});
// 复制原始扫描数据
Array.Copy(jpegData, 0, newJpegData, headerLength, jpegData.Length);
// 添加原始 JPEG 扫描数据
jpegBytes.AddRange(jpegData);
// 复制尾部
Array.Copy(completeJpeg, footerStart, newJpegData, headerLength + jpegData.Length, footerLength);
// EOI (End of Image)
jpegBytes.AddRange(new byte[] { 0xFF, 0xD9 });
return newJpegData;
}
}
// 如果无法智能合并,返回完整的模板 JPEG
return completeJpeg;
return jpegBytes.ToArray();
}
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

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

@@ -183,7 +183,7 @@ public class JtagController : ControllerBase
logger.Info($"User {username} processing bitstream file of size: {fileBytes.Length} bytes");
// 定义进度跟踪
var taskId = _tracker.CreateTask(10000);
var taskId = _tracker.CreateTask(8000);
_tracker.AdvanceProgress(taskId, 10);
_ = Task.Run(async () =>

View File

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

@@ -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,6 +10,7 @@ namespace server.Controllers;
/// 示波器API控制器 - 普通用户权限
/// </summary>
[ApiController]
[EnableCors("Development")]
[Route("api/[controller]")]
[Authorize]
public class OscilloscopeApiController : ControllerBase
@@ -20,7 +22,7 @@ public class OscilloscopeApiController : ControllerBase
/// <summary>
/// 获取示波器实例
/// </summary>
private Oscilloscope? GetOscilloscope()
private OscilloscopeCtrl? GetOscilloscope()
{
try
{
@@ -41,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)
{
@@ -56,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
{
@@ -118,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);
@@ -151,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)]
@@ -185,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)]
@@ -219,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)]
@@ -270,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)
};
@@ -293,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)]
@@ -338,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)]
@@ -387,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)]
@@ -415,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

@@ -37,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("只有管理员可以添加模板资源");

View File

@@ -146,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)
{
@@ -155,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)
{

View File

@@ -135,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}"));
@@ -149,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计算失败");

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

@@ -32,15 +32,9 @@ public class DigitalTubeTaskStatus
{
public int Frequency { get; set; } = 100;
public bool IsRunning { get; set; } = false;
public DigitalTubeTaskStatus(ScanTaskInfo info)
{
Frequency = info.Frequency;
IsRunning = info.IsRunning;
}
}
public class ScanTaskInfo
class DigitalTubesScanTaskInfo
{
public string BoardID { get; set; }
public string ClientID { get; set; }
@@ -50,13 +44,22 @@ public class ScanTaskInfo
public int Frequency { get; set; } = 100;
public bool IsRunning { get; set; } = false;
public ScanTaskInfo(
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]
@@ -67,7 +70,7 @@ public class DigitalTubesHub : Hub<IDigitalTubesReceiver>, IDigitalTubesHub
private readonly IHubContext<DigitalTubesHub, IDigitalTubesReceiver> _hubContext;
private readonly Database.UserManager _userManager = new();
private ConcurrentDictionary<(string, string), ScanTaskInfo> _scanTasks = new();
private static ConcurrentDictionary<string, DigitalTubesScanTaskInfo> _scanTasks = new();
public DigitalTubesHub(IHubContext<DigitalTubesHub, IDigitalTubesReceiver> hubContext)
{
@@ -100,7 +103,7 @@ public class DigitalTubesHub : Hub<IDigitalTubesReceiver>, IDigitalTubesHub
return boardRet.Value.Value;
}
private Task ScanAllTubes(ScanTaskInfo scanInfo)
private Task ScanAllTubes(DigitalTubesScanTaskInfo scanInfo)
{
var token = scanInfo.CTS.Token;
return Task.Run(async () =>
@@ -157,15 +160,15 @@ public class DigitalTubesHub : Hub<IDigitalTubesReceiver>, IDigitalTubesHub
try
{
var board = TryGetBoard().OrThrow(() => new Exception("Board not found"));
var key = (board.ID.ToString(), Context.ConnectionId);
var key = board.ID.ToString();
if (_scanTasks.TryGetValue(key, out var existing) && existing.IsRunning)
return true;
var cts = new CancellationTokenSource();
var scanTaskInfo = new ScanTaskInfo(
var scanTaskInfo = new DigitalTubesScanTaskInfo(
board.ID.ToString(), Context.ConnectionId,
new SevenDigitalTubesCtrl(board.IpAddr, board.Port, 0)
new SevenDigitalTubesCtrl(board.IpAddr, board.Port, 6)
);
scanTaskInfo.ScanTask = ScanAllTubes(scanTaskInfo);
@@ -184,7 +187,7 @@ public class DigitalTubesHub : Hub<IDigitalTubesReceiver>, IDigitalTubesHub
try
{
var board = TryGetBoard().OrThrow(() => new Exception("Board not found"));
var key = (board.ID.ToString(), Context.ConnectionId);
var key = board.ID.ToString();
if (_scanTasks.TryRemove(key, out var scanInfo))
{
@@ -211,7 +214,7 @@ public class DigitalTubesHub : Hub<IDigitalTubesReceiver>, IDigitalTubesHub
return false;
var board = TryGetBoard().OrThrow(() => new Exception("Board not found"));
var key = (board.ID.ToString(), Context.ConnectionId);
var key = board.ID.ToString();
if (_scanTasks.TryGetValue(key, out var scanInfo) && scanInfo.IsRunning)
{
@@ -236,11 +239,11 @@ public class DigitalTubesHub : Hub<IDigitalTubesReceiver>, IDigitalTubesHub
try
{
var board = TryGetBoard().OrThrow(() => new Exception("Board not found"));
var key = (board.ID.ToString(), Context.ConnectionId);
var key = board.ID.ToString();
if (_scanTasks.TryGetValue(key, out var scanInfo))
{
return new DigitalTubeTaskStatus(scanInfo);
return scanInfo.ToDigitalTubeTaskStatus();
}
else
{

View File

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

View File

@@ -0,0 +1,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

@@ -6,6 +6,8 @@ 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服务器
@@ -49,7 +51,7 @@ public sealed class MsgBus
/// 通信总线初始化
/// </summary>
/// <returns>无</returns>
public static void Init()
public static async void Init()
{
if (!ArpClient.IsAdministrator())
{
@@ -57,6 +59,10 @@ public sealed 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

@@ -7,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
@@ -21,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输入客户端
@@ -44,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}");
@@ -75,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)
@@ -99,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;
@@ -110,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
@@ -142,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
{
@@ -181,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}");
@@ -192,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}");
@@ -207,11 +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));
}
@@ -223,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(
@@ -243,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}");
@@ -274,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}");
@@ -339,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)
@@ -379,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;
}
@@ -510,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

@@ -385,6 +385,7 @@ public class Jtag
private const int CLOCK_FREQ = 50; // MHz
readonly int timeout;
readonly int taskID = 10;
readonly int port;
/// <summary>
@@ -415,7 +416,7 @@ public class Jtag
{
BurstType = BurstType.FixedBurst,
BurstLength = 0,
CommandID = 0,
CommandID = (byte)this.taskID,
Address = devAddr,
IsWrite = false,
};
@@ -429,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"));
@@ -449,7 +450,7 @@ public class Jtag
UInt32 resultMask = 0xFF_FF_FF_FF, UInt32 delayMilliseconds = 0, string progressId = "")
{
{
var ret = await UDPClientPool.WriteAddr(this.ep, 0, devAddr, data, this.timeout, progressId);
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"));
}
@@ -458,7 +459,7 @@ public class Jtag
await Task.Delay(TimeSpan.FromMilliseconds(delayMilliseconds));
{
var ret = await UDPClientPool.ReadAddrWithWait(this.ep, 0, JtagAddr.STATE, result, resultMask, 0, this.timeout);
var ret = await UDPClientPool.ReadAddrWithWait(this.ep, this.taskID, JtagAddr.STATE, result, resultMask, 0, this.timeout);
if (!ret.IsSuccessful) return new(ret.Error);
_progressTracker?.AdvanceProgress(progressId, 10);
return ret.Value;
@@ -471,7 +472,7 @@ public class Jtag
{
{
var ret = await UDPClientPool.WriteAddr(
this.ep, 0, devAddr, data, this.timeout, progressId);
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"));
}
@@ -480,7 +481,7 @@ public class Jtag
await Task.Delay(TimeSpan.FromMilliseconds(delayMilliseconds));
{
var ret = await UDPClientPool.ReadAddrWithWait(this.ep, 0, JtagAddr.STATE, result, resultMask, 0, this.timeout);
var ret = await UDPClientPool.ReadAddrWithWait(this.ep, this.taskID, JtagAddr.STATE, result, resultMask, 0, this.timeout);
if (!ret.IsSuccessful) return new(ret.Error);
_progressTracker.AdvanceProgress(progressId, 10);
return ret.Value;
@@ -625,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);
@@ -642,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;
@@ -680,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;
@@ -719,9 +720,9 @@ public class Jtag
byte[] bitstream, string progressId = "")
{
// Clear Data
MsgBus.UDPServer.ClearUDPData(this.address, 0);
MsgBus.UDPServer.ClearUDPData(this.address, this.taskID);
logger.Trace($"Clear up udp server {this.address,0} receive data");
logger.Trace($"Clear up udp server {this.address} {this.taskID} receive data");
_progressTracker.AdvanceProgress(progressId, 10);
@@ -756,7 +757,7 @@ public class Jtag
logger.Trace("Jtag ready to write bitstream");
ret = await IdleDelay(100000);
ret = await IdleDelay(1000);
if (!ret.IsSuccessful) return new(ret.Error);
else if (!ret.Value) return new(new Exception("Jtag IDLE Delay Failed"));
_progressTracker.AdvanceProgress(progressId, 10);
@@ -784,7 +785,7 @@ public class Jtag
logger.Trace("Jtag reset device");
ret = await IdleDelay(10000);
ret = await IdleDelay(1000);
if (!ret.IsSuccessful) return new(ret.Error);
else if (!ret.Value) return new(new Exception("Jtag IDLE Delay Failed"));
_progressTracker.AdvanceProgress(progressId, 10);
@@ -819,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;
@@ -886,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

@@ -1,4 +1,3 @@
using System.Collections;
using System.Net;
using DotNext;
@@ -11,9 +10,6 @@ class SwitchCtrlAddr
public const UInt32 ENABLE = BASE;
}
/// <summary>
/// 矩阵键盘外设类,用于控制和管理矩阵键盘的功能。
/// </summary>
public class SwitchCtrl
{
private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();

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,7 +17,7 @@ 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; }
@@ -102,7 +102,8 @@ public class HttpHdmiVideoStreamService : BackgroundService
var client = _clientDict[key];
client.CTS.Cancel();
var disableResult = await client.JpegClient.SetEnable(false);
// var disableResult = await client.JpegClient.SetEnable(false);
var disableResult = await client.HdmiInClient.SetTransEnable(false);
if (disableResult)
{
logger.Info("Successfully disabled HDMI transmission");
@@ -111,6 +112,8 @@ public class HttpHdmiVideoStreamService : BackgroundService
{
logger.Error($"Failed to disable HDMI transmission");
}
client.CTS = new CancellationTokenSource();
}
catch (Exception ex)
{
@@ -120,53 +123,51 @@ public class HttpHdmiVideoStreamService : BackgroundService
private async Task<HdmiVideoStreamClient?> GetOrCreateClientAsync(string boardId)
{
if (_clientDict.TryGetValue(boardId, out var client))
if (!_clientDict.TryGetValue(boardId, out var client))
{
client.Width = client.JpegClient.Width;
client.Height = client.JpegClient.Height;
return client;
var userManager = new Database.UserManager();
var boardRet = userManager.GetBoardByID(Guid.Parse(boardId));
if (!boardRet.IsSuccessful || !boardRet.Value.HasValue)
{
logger.Error($"Failed to get board with ID {boardId}");
return null;
}
var board = boardRet.Value.Value;
client = new HdmiVideoStreamClient()
{
HdmiInClient = new HdmiIn(board.IpAddr, board.Port, 9),
// JpegClient = new Jpeg(board.IpAddr, board.Port, 1),
CTS = new CancellationTokenSource(),
Offset = 0
};
}
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, 1),
JpegClient = new Jpeg(board.IpAddr, board.Port, 1),
CTS = new CancellationTokenSource(),
Offset = 0
};
// 启用HDMI传输
try
{
// var hdmiEnableRet = await client.JpegClient.EnableTrans(true);
// if (!hdmiEnableRet.IsSuccessful)
// {
// logger.Error($"Failed to enable HDMI transmission for board {boardId}: {hdmiEnableRet.Error}");
// return null;
// }
// logger.Info($"Successfully enabled HDMI transmission for board {boardId}");
var jpegEnableRet = await client.JpegClient.Init(true);
if (!jpegEnableRet.IsSuccessful)
var hdmiEnableRet = await client.HdmiInClient.Init(true);
if (!hdmiEnableRet.IsSuccessful)
{
logger.Error($"Failed to enable JPEG transmission for board {boardId}: {jpegEnableRet.Error}");
logger.Error($"Failed to enable HDMI transmission for board {boardId}: {hdmiEnableRet.Error}");
return null;
}
logger.Info($"Successfully enabled JPEG transmission for board {boardId}");
logger.Info($"Successfully enabled HDMI transmission for board {boardId}");
client.Width = client.JpegClient.Width;
client.Height = client.JpegClient.Height;
// 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)
{
@@ -195,15 +196,16 @@ public class HttpHdmiVideoStreamService : BackgroundService
return;
}
var hdmiInToken = _clientDict[boardId].CTS.Token;
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")
{
@@ -223,24 +225,47 @@ public class HttpHdmiVideoStreamService : BackgroundService
logger.Debug("处理HDMI快照请求");
// 从HDMI读取RGB565数据
var frameResult = await client.JpegClient.GetMultiFrames((uint)client.Offset);
if (!frameResult.IsSuccessful || frameResult.Value == null || frameResult.Value.Count == 0)
{
logger.Error("HDMI快照获取失败");
response.StatusCode = 500;
var errorBytes = System.Text.Encoding.UTF8.GetBytes("Failed to get HDMI snapshot");
await response.OutputStream.WriteAsync(errorBytes, 0, errorBytes.Length, cancellationToken);
response.Close();
return;
}
// var 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 jpegImage = Common.Image.CompleteJpegData(jpegData, client.Width, client.Height);
if (!jpegImage.IsSuccessful)
// 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("JPEG数据补全失败");
logger.Error("获取HDMI MJPEG帧失败");
response.StatusCode = 500;
var errorBytes = System.Text.Encoding.UTF8.GetBytes("Failed to complete JPEG data");
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;
@@ -248,13 +273,13 @@ public class HttpHdmiVideoStreamService : BackgroundService
// 设置响应头参考Camera版本
response.ContentType = "image/jpeg";
response.ContentLength64 = jpegImage.Value.Length;
response.ContentLength64 = jpegImage.Value.data.Length;
response.Headers.Add("Cache-Control", "no-cache, no-store, must-revalidate");
await response.OutputStream.WriteAsync(jpegImage.Value, 0, jpegImage.Value.Length, cancellationToken);
await response.OutputStream.WriteAsync(jpegImage.Value.data, 0, jpegImage.Value.data.Length, cancellationToken);
await response.OutputStream.FlushAsync(cancellationToken);
logger.Debug("已发送HDMI快照图像大小{Size} 字节", jpegImage.Value.Length);
logger.Debug("已发送HDMI快照图像大小{Size} 字节", jpegImage.Value.data.Length);
}
catch (Exception ex)
{
@@ -263,6 +288,7 @@ public class HttpHdmiVideoStreamService : BackgroundService
}
finally
{
response.StatusCode = 200;
response.Close();
}
}
@@ -280,57 +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)
{
var frameStartTime = DateTime.UtcNow;
var frameResult =
await client.JpegClient.GetMultiFrames((uint)client.Offset);
if (!frameResult.IsSuccessful || frameResult.Value == null || frameResult.Value.Count == 0)
var frameRet = await client.HdmiInClient.GetMJpegFrame();
if (!frameRet.HasValue)
{
logger.Error("获取HDMI帧失败");
await Task.Delay(100, cancellationToken);
continue;
}
var frame = frameRet.Value;
foreach (var framebytes in frameResult.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)
{
var jpegImage = Common.Image.CompleteJpegData(framebytes, client.Width, client.Height);
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);
}
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)
@@ -342,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)

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();
}
@@ -101,22 +97,33 @@ public class HttpVideoStreamService : BackgroundService
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));
private static async Task<UsbCameraCapture> InitializeUsbCamera(CancellationToken token)
{
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))
{
@@ -135,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;
@@ -170,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();
@@ -217,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")
{
@@ -281,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();
@@ -310,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;
@@ -386,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;
@@ -508,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;
@@ -568,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 = $"获取摄像头失败";
@@ -621,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)
{
@@ -655,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)
{
@@ -679,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);
@@ -702,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);
@@ -738,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");
@@ -757,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}");
}
}
@@ -782,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,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

@@ -183,6 +183,22 @@ public sealed class UDPClientPool
return await Task.Run(() => { return SendDataPack(endPoint, pkg); });
}
/// <summary>
/// 发送重置信号
/// </summary>
/// <param name="endPoint">IP端点IP地址与端口</param>
/// <returns>是否成功</returns>
public async static ValueTask<bool> SendResetSignal(IPEndPoint endPoint)
{
return await Task.Run(async () =>
{
var ret = SendAddrPack(endPoint,
new WebProtocol.SendAddrPackage(BurstType.FixedBurst, 0, true, 0, 0xF0F0F0F0));
await Task.Delay(100);
return ret;
});
}
/// <summary>
/// 读取设备地址数据
/// </summary>
@@ -219,8 +235,7 @@ public sealed 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"));
@@ -389,8 +404,7 @@ public sealed 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)
@@ -606,8 +620,7 @@ public sealed class UDPClientPool
return new(new Exception("Message bus not working!"));
// Wait for Write Ack
var udpWriteAck = await MsgBus.UDPServer.WaitForAckAsync(
endPoint.Address.ToString(), taskID, endPoint.Port, timeout);
var udpWriteAck = await MsgBus.UDPServer.WaitForAckAsync(endPoint, taskID, timeout);
if (!udpWriteAck.IsSuccessful) return new(udpWriteAck.Error);
_progressTracker.AdvanceProgress(progressId, 10);
@@ -671,7 +684,7 @@ public sealed 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)

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);
}
/**
@@ -5569,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(/[?&]$/, "");
@@ -9090,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))
@@ -9121,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;
}
@@ -9139,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) {
@@ -9210,17 +9186,11 @@ export class OscilloscopeDataResponse implements IOscilloscopeDataResponse {
}
}
/** 示波器状态和数据 */
export interface IOscilloscopeDataResponse {
/** AD采样频率 */
adFrequency: number;
/** AD采样幅度 */
adVpp: number;
/** AD采样最大值 */
adMax: number;
/** AD采样最小值 */
adMin: number;
/** 波形数据Base64编码 */
waveformData: string;
}

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

@@ -29,6 +29,7 @@ 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,
SevenSegmentDisplayUltimate: 0.4,
@@ -48,6 +49,7 @@ 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: "SevenSegmentDisplayUltimate", name: "数码管" },

View File

@@ -1,14 +1,35 @@
import { autoResetRef, createInjectionState } from "@vueuse/core";
import { shallowRef, reactive, ref, computed } from "vue";
import { Mutex } from "async-mutex";
import {
OscilloscopeFullConfig,
OscilloscopeDataResponse,
OscilloscopeApiClient,
} 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[];
@@ -22,41 +43,106 @@ 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>();
// Global Store
const alert = useRequiredInjection(useAlertStore);
// Data
const oscData = shallowRef<OscilloscopeDataType>();
const clearOscilloscopeData = () => {
oscData.value = undefined;
};
// SignalR Hub
const oscilloscopeHub = shallowRef<{
connection: HubConnection;
proxy: IOscilloscopeHub;
} | null>(null);
const oscilloscopeReceiver: IOscilloscopeReceiver = {
onDataReceived: async (data) => {
analyzeOscilloscopeData(data);
},
};
onMounted(() => {
initHub();
});
onUnmounted(() => {
clearHub();
});
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 };
}
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");
}
return oscilloscopeHub.value.proxy;
}
// 互斥锁
const operationMutex = new Mutex();
// 状态
const isApplying = ref(false);
const isCapturing = ref(false);
const isAutoApplying = ref(false);
// 配置
const config = reactive<OscilloscopeFullConfig>(
new OscilloscopeFullConfig({ ...DEFAULT_CONFIG }),
);
const config = reactive<OscilloscopeFullConfig>({ ...DEFAULT_CONFIG });
watchDebounced(
config,
() => {
if (!isAutoApplying.value) return;
// 采样点数(由后端数据决定)
const sampleCount = ref(0);
// 采样周期ns由adFrequency计算
const samplePeriodNs = computed(() =>
oscData.value?.adFrequency
? 1_000_000_000 / oscData.value.adFrequency
: 200,
if (
!isApplying.value ||
!isCapturing.value ||
!operationMutex.isLocked()
) {
applyConfiguration();
}
},
{ debounce: 200, maxWait: 1000 },
);
// 应用配置
@@ -68,14 +154,19 @@ const [useProvideOscilloscope, useOscilloscopeState] = createInjectionState(
const release = await operationMutex.acquire();
isApplying.value = true;
try {
const client = AuthManager.createClient(OscilloscopeApiClient);
const success = await client.initialize({ ...config });
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;
@@ -89,68 +180,63 @@ const [useProvideOscilloscope, useOscilloscopeState] = createInjectionState(
alert.info("配置已重置", 2000);
};
const clearOscilloscopeData = () => {
oscData.value = undefined;
// 采样点数(由后端数据决定)
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);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
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) / 1000, // us
);
const y = Array.from(bytes);
oscData.value = {
x,
y,
xUnit: "us",
yUnit: "V",
adFrequency: aDFrequency,
adVpp: resp.adVpp,
adMax: resp.adMax,
adMin: resp.adMin,
};
console.log("解析后的参数:", resp, oscData.value); // 添加调试日志
};
// 获取数据
const getOscilloscopeData = async () => {
try {
const client = AuthManager.createClient(OscilloscopeApiClient);
const resp: OscilloscopeDataResponse = await client.getData();
// 解析波形数据
const binaryString = atob(resp.waveformData);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
sampleCount.value = bytes.length;
// 构建时间轴
const x = Array.from(
{ length: bytes.length },
(_, i) => (i * samplePeriodNs.value) / 1000, // us
);
const y = Array.from(bytes);
oscData.value = {
x,
y,
xUnit: "us",
yUnit: "V",
adFrequency: resp.adFrequency,
adVpp: resp.adVpp,
adMax: resp.adMax,
adMin: resp.adMin,
};
const proxy = getHubProxy();
const resp = await proxy.getData();
analyzeOscilloscopeData(resp);
} 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()) {
@@ -160,17 +246,13 @@ const [useProvideOscilloscope, useOscilloscopeState] = createInjectionState(
isCapturing.value = true;
const release = await operationMutex.acquire();
try {
const client = AuthManager.createClient(OscilloscopeApiClient);
const started = await client.startCapture();
const proxy = getHubProxy();
const started = await proxy.startCapture();
if (!started) throw new Error("无法启动捕获");
alert.info("开始捕获...", 2000);
// 启动定时刷新
startAutoRefresh();
} catch (error) {
alert.error("捕获失败", 3000);
isCapturing.value = false;
stopAutoRefresh();
} finally {
release();
}
@@ -182,13 +264,12 @@ const [useProvideOscilloscope, useOscilloscopeState] = createInjectionState(
alert.warn("当前没有正在进行的捕获操作", 2000);
return;
}
isCapturing.value = false;
stopAutoRefresh();
const release = await operationMutex.acquire();
try {
const client = AuthManager.createClient(OscilloscopeApiClient);
const stopped = await client.stopCapture();
const proxy = getHubProxy();
const stopped = await proxy.stopCapture();
if (!stopped) throw new Error("无法停止捕获");
isCapturing.value = false;
alert.info("捕获已停止", 2000);
} catch (error) {
alert.error("停止捕获失败", 3000);
@@ -197,6 +278,14 @@ const [useProvideOscilloscope, useOscilloscopeState] = createInjectionState(
}
};
const toggleCapture = async () => {
if (isCapturing.value) {
await stopCapture();
} else {
await startCapture();
}
};
// 更新触发参数
const updateTrigger = async (level: number, risingEdge: boolean) => {
const client = AuthManager.createClient(OscilloscopeApiClient);
@@ -279,9 +368,9 @@ const [useProvideOscilloscope, useOscilloscopeState] = createInjectionState(
config,
isApplying,
isCapturing,
isAutoApplying,
sampleCount,
samplePeriodNs,
refreshIntervalMs,
applyConfiguration,
resetConfiguration,
@@ -289,6 +378,7 @@ const [useProvideOscilloscope, useOscilloscopeState] = createInjectionState(
getOscilloscopeData,
startCapture,
stopCapture,
toggleCapture,
updateTrigger,
updateSampling,
refreshRAM,

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 { ExamClient, ResourceClient, type ExamInfo } from "@/APIClient";
import {
ExamClient,
ResourceClient,
ResourcePurpose,
type ExamInfo,
} from "@/APIClient";
// 接口定义
interface Tutorial {
@@ -146,7 +151,7 @@ onMounted(async () => {
const resourceList = await resourceClient.getResourceList(
exam.id,
"cover",
"template",
ResourcePurpose.Template,
);
if (resourceList && resourceList.length > 0) {
// 使用第一个封面资源

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

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

@@ -41,7 +41,7 @@
<!-- 引脚仅在非数字孪生模式下显示 -->
<div
v-if="!props.enableDigitalTwin"
v-if="!eqps.enableSevenSegmentDisplay"
v-for="pin in props.pins"
:key="pin.pinId"
:style="{
@@ -74,6 +74,12 @@ 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解决一切
@@ -82,9 +88,9 @@ import { useEquipments } from "@/stores/equipments";
interface Props {
size?: number;
color?: string;
enableDigitalTwin?: boolean;
// enableDigitalTwin?: boolean;
digitalTwinNum?: number;
afterglowDuration?: number;
// afterglowDuration?: number;
cathodeType?: "common" | "anode";
pins?: Array<{
pinId: string;
@@ -97,8 +103,8 @@ interface Props {
const props = withDefaults(defineProps<Props>(), {
size: 1,
color: "red",
enableDigitalTwin: false,
digitalTwinNum: 0,
// enableDigitalTwin: false,
digitalTwinNum: 1,
afterglowDuration: 500,
cathodeType: "common",
pins: () => [
@@ -158,7 +164,7 @@ function isBitSet(byte: number, bit: number): boolean {
}
function isSegmentActive(segmentId: keyof typeof SEGMENT_BITS): boolean {
if (props.enableDigitalTwin) {
if (eqps.enableSevenSegmentDisplay) {
// 数字孪生模式余晖优先然后是当前byte
const bit = SEGMENT_BITS[segmentId];
return (
@@ -174,18 +180,16 @@ function isSegmentActive(segmentId: keyof typeof SEGMENT_BITS): boolean {
// SignalR数字孪生集成
// ============================================================================
const eqps = useEquipments();
async function initDigitalTwin() {
if (
!props.enableDigitalTwin ||
props.digitalTwinNum < 0 ||
props.digitalTwinNum > 31
!eqps.enableSevenSegmentDisplay ||
props.digitalTwinNum <= 0 ||
props.digitalTwinNum > 32
)
return;
try {
eqps.sevenSegmentDisplaySetOnOff(props.enableDigitalTwin);
eqps.sevenSegmentDisplaySetOnOff(eqps.enableSevenSegmentDisplay);
console.log(
`Digital twin initialized for address: ${props.digitalTwinNum}`,
@@ -200,12 +204,14 @@ watch(
() => {
if (
!eqps.sevenSegmentDisplayData ||
props.digitalTwinNum < 0 ||
props.digitalTwinNum > 31
props.digitalTwinNum <= 0 ||
props.digitalTwinNum > 32
)
return;
handleDigitalTwinData(eqps.sevenSegmentDisplayData[props.digitalTwinNum]);
handleDigitalTwinData(
eqps.sevenSegmentDisplayData[props.digitalTwinNum - 1],
);
},
);
@@ -234,24 +240,24 @@ function updateDisplayByte(newByte: number) {
const oldByte = displayByte.value;
displayByte.value = newByte;
// 启动余晖效果
if (oldByte !== 0 && newByte !== oldByte) {
startAfterglow(oldByte);
}
// // 启动余晖效果
// if (oldByte !== 0 && newByte !== oldByte) {
// startAfterglow(oldByte);
// }
}
function startAfterglow(byte: number) {
afterglowByte.value = byte;
// function startAfterglow(byte: number) {
// afterglowByte.value = byte;
if (afterglowTimer.value) {
clearTimeout(afterglowTimer.value);
}
// if (afterglowTimer.value) {
// clearTimeout(afterglowTimer.value);
// }
afterglowTimer.value = setTimeout(() => {
afterglowByte.value = 0;
afterglowTimer.value = null;
}, props.afterglowDuration);
}
// afterglowTimer.value = setTimeout(() => {
// afterglowByte.value = 0;
// afterglowTimer.value = null;
// }, props.afterglowDuration);
// }
function cleanupDigitalTwin() {
eqps.sevenSegmentDisplaySetOnOff(false);
@@ -265,7 +271,7 @@ const { getConstraintState, onConstraintStateChange } = useConstraintsStore();
let constraintUnsubscribe: (() => void) | null = null;
function updateConstraintStates() {
if (props.enableDigitalTwin) return; // 数字孪生模式下忽略约束
if (eqps.enableSevenSegmentDisplay) return; // 数字孪生模式下忽略约束
// 获取COM状态
const comPin = props.pins.find((p) => p.pinId === "COM");
@@ -328,7 +334,7 @@ const pinRefs = ref<Record<string, any>>({});
// ============================================================================
onMounted(async () => {
if (props.enableDigitalTwin) {
if (eqps.enableSevenSegmentDisplay) {
await initDigitalTwin();
} else {
constraintUnsubscribe = onConstraintStateChange(updateConstraintStates);
@@ -349,25 +355,24 @@ onUnmounted(() => {
});
// 监听模式切换
watch(
() => [props.enableDigitalTwin],
async () => {
// 清理旧模式
cleanupDigitalTwin();
if (constraintUnsubscribe) {
constraintUnsubscribe();
constraintUnsubscribe = null;
}
// watch(
// () => [eqps.enableSevenSegmentDisplay],
// async () => {
// // 清理旧模式
// if (constraintUnsubscribe) {
// constraintUnsubscribe();
// constraintUnsubscribe = null;
// }
// 初始化新模式
if (props.enableDigitalTwin) {
await initDigitalTwin();
} else {
constraintUnsubscribe = onConstraintStateChange(updateConstraintStates);
updateConstraintStates();
}
},
);
// // 初始化新模式
// if (eqps.enableSevenSegmentDisplay) {
// await initDigitalTwin();
// } else {
// constraintUnsubscribe = onConstraintStateChange(updateConstraintStates);
// updateConstraintStates();
// }
// },
// );
</script>
<style scoped>
@@ -393,9 +398,9 @@ export function getDefaultProps() {
return {
size: 1,
color: "red",
enableDigitalTwin: false,
digitalTwinNum: 0,
afterglowDuration: 500,
// enableDigitalTwin: false,
digitalTwinNum: 1,
// afterglowDuration: 500,
cathodeType: "common",
pins: [
{ pinId: "a", constraint: "", x: 10, y: 170 },

View File

@@ -108,6 +108,7 @@ import { ref, computed, watch, onMounted } from "vue";
interface Props {
size?: number;
componentId?: string;
enableDigitalTwin?: boolean;
switchCount?: number;
initialValues?: string;
@@ -194,8 +195,10 @@ function setBtnStatus(idx: number, isOn: boolean) {
watch(
() => props.enableDigitalTwin,
(newVal) => {
const client = getClient();
client.setEnable(newVal);
if (props.componentId) {
const client = getClient();
client.setEnable(newVal);
}
},
{ immediate: true },
);
@@ -204,7 +207,7 @@ watch(
() => [switchCount.value, props.initialValues],
() => {
btnStatus.value = parseInitialValues();
updateStatus(btnStatus.value);
if (props.componentId) updateStatus(btnStatus.value);
},
);
</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";
@@ -48,6 +48,8 @@ export const useEquipments = defineStore("equipments", () => {
1000,
new Error("JtagClient Mutex Timeout!"),
);
// jtag Hub
const jtagHubConnection = ref<HubConnection>();
const jtagHubProxy = ref<IJtagHub>();
@@ -300,38 +302,74 @@ export const useEquipments = defineStore("equipments", () => {
const enableSevenSegmentDisplay = ref(false);
const sevenSegmentDisplayFrequency = ref(100);
const sevenSegmentDisplayData = ref<Uint8Array>();
const sevenSegmentDisplayHub = ref<HubConnection>();
const sevenSegmentDisplayHubProxy = ref<IDigitalTubesHub>();
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) {
if (!sevenSegmentDisplayHub.value || !sevenSegmentDisplayHubProxy.value)
return;
if (sevenSegmentDisplayHub.value.state === HubConnectionState.Disconnected)
await sevenSegmentDisplayHub.value.start();
const proxy = getSevenDigitalTubesHubProxy();
if (enable) {
await sevenSegmentDisplayHubProxy.value.startScan();
await proxy.startScan();
enableSevenSegmentDisplay.value = true;
} else {
await sevenSegmentDisplayHubProxy.value.stopScan();
await proxy.stopScan();
enableSevenSegmentDisplay.value = false;
}
}
async function sevenSegmentDisplaySetFrequency(frequency: number) {
if (!sevenSegmentDisplayHub.value || !sevenSegmentDisplayHubProxy.value)
return;
if (sevenSegmentDisplayHub.value.state === HubConnectionState.Disconnected)
await sevenSegmentDisplayHub.value.start();
await sevenSegmentDisplayHubProxy.value.setFrequency(frequency);
const proxy = getSevenDigitalTubesHubProxy();
return await proxy.setFrequency(frequency);
}
async function sevenSegmentDisplayGetStatus() {
if (!sevenSegmentDisplayHub.value || !sevenSegmentDisplayHubProxy.value)
return;
if (sevenSegmentDisplayHub.value.state === HubConnectionState.Disconnected)
await sevenSegmentDisplayHub.value.start();
return await sevenSegmentDisplayHubProxy.value.getStatus();
const proxy = getSevenDigitalTubesHubProxy();
return await proxy.getStatus();
}
async function handleSevenSegmentDisplayOnReceive(msg: string) {
@@ -339,31 +377,6 @@ export const useEquipments = defineStore("equipments", () => {
sevenSegmentDisplayData.value = new Uint8Array(bytes);
}
onMounted(async () => {
// 每次挂载都重新创建连接
sevenSegmentDisplayHub.value =
AuthManager.createHubConnection("DigitalTubesHub");
sevenSegmentDisplayHubProxy.value = getHubProxyFactory(
"IDigitalTubesHub",
).createHubProxy(sevenSegmentDisplayHub.value);
getReceiverRegister("IDigitalTubesReceiver").register(
sevenSegmentDisplayHub.value,
{
onReceive: handleSevenSegmentDisplayOnReceive,
},
);
});
onUnmounted(() => {
// 断开连接,清理资源
if (sevenSegmentDisplayHub.value) {
sevenSegmentDisplayHub.value.stop();
sevenSegmentDisplayHub.value = undefined;
sevenSegmentDisplayHubProxy.value = undefined;
}
});
return {
boardAddr,
boardPort,

View File

@@ -45,7 +45,12 @@ export class AuthManager {
// SignalR连接 - 简单明了
static createHubConnection(
hubPath: "ProgressHub" | "JtagHub" | "DigitalTubesHub",
hubPath:
| "ProgressHub"
| "JtagHub"
| "DigitalTubesHub"
| "RotaryEncoderHub"
| "OscilloscopeHub",
) {
return new HubConnectionBuilder()
.withUrl(`http://127.0.0.1:5000/hubs/${hubPath}`, {

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 { IDigitalTubesHub, IJtagHub, IProgressHub, IDigitalTubesReceiver, IJtagReceiver, IProgressReceiver } from './server.Hubs';
import type { DigitalTubeTaskStatus, 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
@@ -45,7 +47,10 @@ class ReceiverMethodSubscription implements Disposable {
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) => {
@@ -55,15 +60,27 @@ export const getHubProxyFactory = ((hubType: string) => {
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) => {
@@ -73,9 +90,18 @@ export const getReceiverRegister = ((receiverType: string) => {
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
@@ -142,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();
@@ -171,6 +246,68 @@ class IProgressHub_HubProxy implements IProgressHub {
}
}
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
@@ -216,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();
@@ -237,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,9 @@
/* tslint:disable */
// @ts-nocheck
import type { IStreamResult, Subject } from '@microsoft/signalr';
import type { DigitalTubeTaskStatus, 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 = {
/**
@@ -42,6 +44,46 @@ 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
@@ -60,6 +102,49 @@ export type IProgressHub = {
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[]
@@ -76,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
@@ -84,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

@@ -10,6 +10,36 @@ export type DigitalTubeTaskStatus = {
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 {
Running = 0,
@@ -30,3 +60,9 @@ export type ProgressInfo = {
errorMessage: string;
}
/** Transpiled from server.Hubs.WS2812TaskStatus */
export type WS2812TaskStatus = {
/** Transpiled from bool */
isRunning: boolean;
}

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,11 +405,13 @@ import {
BinaryIcon,
FileArchiveIcon,
FileJsonIcon,
XIcon,
} from "lucide-vue-next";
import {
ExamClient,
ExamDto,
ResourceClient,
ResourcePurpose,
type FileParameter,
} from "@/APIClient";
import { useAlertStore } from "@/components/Alert";
@@ -478,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")
);
});
@@ -615,11 +605,6 @@ const submitCreateExam = async () => {
return;
}
if (!uploadFiles.value.mdFile) {
alert.error("请上传MD文档");
return;
}
isUpdating.value = true;
try {
@@ -685,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文档上传成功");
}
@@ -695,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);
}
@@ -707,7 +702,7 @@ async function uploadExamResources(examId: string) {
};
await client.addResource(
"bitstream",
"template",
ResourcePurpose.Template,
examId,
bitstreamFileParam,
);
@@ -720,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);
}
@@ -732,7 +732,7 @@ async function uploadExamResources(examId: string) {
};
await client.addResource(
"resource",
"template",
ResourcePurpose.Template,
examId,
resourceFileParam,
);
@@ -775,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,6 +210,14 @@
</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">
@@ -265,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,
});
@@ -284,11 +262,23 @@ async function updateCommits() {
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;
@@ -336,10 +326,10 @@ const downloadResources = async () => {
} finally {
downloadingResources.value = false;
}
};
}
// 开始实验
const startExam = () => {
function startExam() {
if (props.selectedExam) {
// 跳转到项目页面传递实验ID
console.log("开始实验:", props.selectedExam.id);
@@ -348,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 { 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,6 +206,31 @@ 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;
@@ -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}`);
}
// 生命周期
@@ -251,11 +283,30 @@ onMounted(async () => {
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

@@ -430,13 +430,13 @@ function startStream() {
}
// 停止播放视频流
function stopStream() {
async function stopStream() {
isPlaying.value = false;
currentVideoSource.value = "";
videoStatus.value = "已停止播放";
const client = AuthManager.createClient(HdmiVideoStreamClient);
client.disableHdmiTransmission();
await client.disableHdmiTransmission();
addLog("info", "停止播放HDMI视频流");
alert?.info("已停止播放HDMI视频流");
@@ -467,8 +467,9 @@ function handleVideoClick() {
}
// 重试连接
function tryReconnect() {
async function tryReconnect() {
hasVideoError.value = false;
await stopStream();
if (endpoint.value) {
startStream();
}

View File

@@ -171,7 +171,12 @@ import { useProvideComponentManager } from "@/components/LabCanvas";
import { useAlertStore } from "@/components/Alert";
import { AuthManager } from "@/utils/AuthManager";
import { useEquipments } from "@/stores/equipments";
import { DataClient, ResourceClient, type Board } from "@/APIClient";
import {
DataClient,
ResourceClient,
ResourcePurpose,
type Board,
} from "@/APIClient";
import { useRoute } from "vue-router";
const route = useRoute();
@@ -257,7 +262,11 @@ async function loadDocumentContent() {
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资源

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

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

@@ -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 {
// 首先验证用户是否已登录