13 Commits

Author SHA1 Message Date
aff9da2a60 feat: 添加下载进度条 2025-08-04 20:00:02 +08:00
alivender
e0ac21d141 feat: 部分修复Hdmi再次启动启动不了的bug 2025-08-04 17:13:50 +08:00
8396b7aaea feat: 支持HDMI关闭传输 2025-08-04 17:00:31 +08:00
alivender
a331494fde add: 完善HDMI输入前后端,现在无法关闭 2025-08-04 16:35:42 +08:00
alivender
e86cd5464e add: 逻辑分析仪可设置采样频率 2025-08-04 14:31:58 +08:00
alivender
04b136117d Merge branch 'master' of ssh://git.swordlost.top:222/SikongJueluo/FPGA_WebLab 2025-08-04 13:27:37 +08:00
alivender
5c87204ef6 feat: 逻辑分析仪深度可用户输入自定义数字 2025-08-04 13:27:35 +08:00
35647d21bb feat: 添加Hdmi视频串流后端 2025-08-04 13:26:20 +08:00
alivender
51b39cee07 add: 添加了HDMI视频流Client 2025-08-04 11:54:58 +08:00
alivender
0bd1ad8a0e add: 添加了960*540分辨率 2025-08-02 21:07:08 +08:00
alivender
f2c7c78b64 feat: JtaggetDR可以一次全部获取到 2025-08-02 16:01:07 +08:00
alivender
2f23ffe482 Merge branch 'master' of ssh://git.swordlost.top:222/SikongJueluo/FPGA_WebLab into dpp 2025-08-02 13:15:07 +08:00
alivender
9904fecbee feat: 统一资源管理 2025-08-02 13:14:01 +08:00
41 changed files with 4056 additions and 1098 deletions

View File

@@ -304,7 +304,7 @@ async function generateSignalRClient(): Promise<void> {
console.log("Generating SignalR TypeScript client..."); console.log("Generating SignalR TypeScript client...");
try { try {
const { stdout, stderr } = await execAsync( const { stdout, stderr } = await execAsync(
"dotnet tsrts --project ./server/server.csproj --output ./src", "dotnet tsrts --project ./server/server.csproj --output ./src/",
); );
if (stdout) console.log(stdout); if (stdout) console.log(stdout);
if (stderr) console.error(stderr); if (stderr) console.error(stderr);

View File

@@ -283,4 +283,28 @@ public class NumberTest
var reversed2 = Number.ReverseBits(new byte[0]); var reversed2 = Number.ReverseBits(new byte[0]);
Assert.Empty(reversed2); Assert.Empty(reversed2);
} }
/// <summary>
/// 测试 GetLength
/// </summary>
[Fact]
public void Test_GetLength()
{
Assert.Equal(5, Number.GetLength(12345));
Assert.Equal(4, Number.GetLength(-123));
Assert.Equal(1, Number.GetLength(0));
}
/// <summary>
/// 测试 IntPow
/// </summary>
[Fact]
public void Test_IntPow()
{
Assert.Equal(8, Number.IntPow(2, 3));
Assert.Equal(1, Number.IntPow(5, 0));
Assert.Equal(0, Number.IntPow(0, 5));
Assert.Equal(7, Number.IntPow(7, 1));
Assert.Equal(81, Number.IntPow(3, 4));
}
} }

View File

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

View File

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

View File

@@ -145,6 +145,12 @@ try
// 添加 HTTP 视频流服务 // 添加 HTTP 视频流服务
builder.Services.AddSingleton<HttpVideoStreamService>(); builder.Services.AddSingleton<HttpVideoStreamService>();
builder.Services.AddHostedService(provider => provider.GetRequiredService<HttpVideoStreamService>()); builder.Services.AddHostedService(provider => provider.GetRequiredService<HttpVideoStreamService>());
builder.Services.AddSingleton<HttpHdmiVideoStreamService>();
builder.Services.AddHostedService(provider => provider.GetRequiredService<HttpHdmiVideoStreamService>());
// 添加进度跟踪服务
builder.Services.AddSingleton<ProgressTrackerService>();
builder.Services.AddHostedService(provider => provider.GetRequiredService<ProgressTrackerService>());
// Application Settings // Application Settings
var app = builder.Build(); var app = builder.Build();
@@ -215,7 +221,8 @@ try
// Router // Router
app.MapControllers(); app.MapControllers();
app.MapHub<server.Hubs.JtagHub.JtagHub>("hubs/JtagHub"); app.MapHub<server.Hubs.JtagHub>("hubs/JtagHub");
app.MapHub<server.Hubs.ProgressHub>("hubs/ProgressHub");
// Setup Program // Setup Program
MsgBus.Init(); MsgBus.Init();

View File

@@ -348,4 +348,37 @@ public class Number
} }
return dstBytes; return dstBytes;
} }
/// <summary>
/// 获取数字的长度
/// </summary>
/// <param name="number">数字</param>
/// <returns>数字的长度</returns>
public static int GetLength(int number)
{
// 将整数转换为字符串
string numberString = number.ToString();
// 返回字符串的长度
return numberString.Length;
}
/// <summary>
/// 计算整形的幂
/// </summary>
/// <param name="x">底数</param>
/// <param name="pow">幂</param>
/// <returns>计算结果</returns>
public static int IntPow(int x, int pow)
{
int ret = 1;
while (pow != 0)
{
if ((pow & 1) == 1)
ret *= x;
x *= x;
pow >>= 1;
}
return ret;
}
} }

View File

@@ -101,22 +101,6 @@ public class ExamController : ControllerBase
public bool IsVisibleToUsers { get; set; } = true; public bool IsVisibleToUsers { get; set; } = true;
} }
/// <summary>
/// 资源信息类
/// </summary>
public class ResourceInfo
{
/// <summary>
/// 资源ID
/// </summary>
public int ID { get; set; }
/// <summary>
/// 资源名称
/// </summary>
public required string Name { get; set; }
}
/// <summary> /// <summary>
/// 创建实验请求类 /// 创建实验请求类
/// </summary> /// </summary>
@@ -304,151 +288,4 @@ public class ExamController : ControllerBase
return StatusCode(StatusCodes.Status500InternalServerError, $"创建实验失败: {ex.Message}"); return StatusCode(StatusCodes.Status500InternalServerError, $"创建实验失败: {ex.Message}");
} }
} }
/// <summary>
/// 添加实验资源
/// </summary>
/// <param name="examId">实验ID</param>
/// <param name="resourceType">资源类型</param>
/// <param name="file">资源文件</param>
/// <returns>添加结果</returns>
[Authorize("Admin")]
[HttpPost("{examId}/resources/{resourceType}")]
[EnableCors("Users")]
[ProducesResponseType(typeof(ResourceInfo), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> AddExamResource(string examId, string resourceType, IFormFile file)
{
if (string.IsNullOrWhiteSpace(examId) || string.IsNullOrWhiteSpace(resourceType) || file == null)
return BadRequest("实验ID、资源类型和文件不能为空");
try
{
using var db = new Database.AppDataConnection();
// 读取文件数据
using var memoryStream = new MemoryStream();
await file.CopyToAsync(memoryStream);
var fileData = memoryStream.ToArray();
var result = db.AddExamResource(examId, resourceType, file.FileName, fileData);
if (!result.IsSuccessful)
{
if (result.Error.Message.Contains("不存在"))
return NotFound(result.Error.Message);
if (result.Error.Message.Contains("已存在"))
return Conflict(result.Error.Message);
logger.Error($"添加实验资源时出错: {result.Error.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"添加实验资源失败: {result.Error.Message}");
}
var resource = result.Value;
var resourceInfo = new ResourceInfo
{
ID = resource.ID,
Name = resource.ResourceName
};
logger.Info($"成功添加实验资源: {examId}/{resourceType}/{file.FileName}");
return CreatedAtAction(nameof(GetExamResourceById), new { resourceId = resource.ID }, resourceInfo);
}
catch (Exception ex)
{
logger.Error($"添加实验资源 {examId}/{resourceType}/{file.FileName} 时出错: {ex.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"添加实验资源失败: {ex.Message}");
}
}
/// <summary>
/// 获取指定实验ID的指定资源类型的所有资源的ID和名称
/// </summary>
/// <param name="examId">实验ID</param>
/// <param name="resourceType">资源类型</param>
/// <returns>资源列表</returns>
[Authorize]
[HttpGet("{examId}/resources/{resourceType}")]
[EnableCors("Users")]
[ProducesResponseType(typeof(ResourceInfo[]), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult GetExamResourceList(string examId, string resourceType)
{
if (string.IsNullOrWhiteSpace(examId) || string.IsNullOrWhiteSpace(resourceType))
return BadRequest("实验ID和资源类型不能为空");
try
{
using var db = new Database.AppDataConnection();
var result = db.GetExamResourceList(examId, resourceType);
if (!result.IsSuccessful)
{
logger.Error($"获取实验资源列表时出错: {result.Error.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"获取实验资源列表失败: {result.Error.Message}");
}
var resources = result.Value.Select(r => new ResourceInfo
{
ID = r.ID,
Name = r.Name
}).ToArray();
logger.Info($"成功获取实验资源列表: {examId}/{resourceType},共 {resources.Length} 个资源");
return Ok(resources);
}
catch (Exception ex)
{
logger.Error($"获取实验资源列表 {examId}/{resourceType} 时出错: {ex.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"获取实验资源列表失败: {ex.Message}");
}
}
/// <summary>
/// 根据资源ID下载资源
/// </summary>
/// <param name="resourceId">资源ID</param>
/// <returns>资源文件</returns>
[HttpGet("resources/{resourceId}")]
[EnableCors("Users")]
[ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult GetExamResourceById(int resourceId)
{
try
{
using var db = new Database.AppDataConnection();
var result = db.GetExamResourceById(resourceId);
if (!result.IsSuccessful)
{
logger.Error($"获取资源时出错: {result.Error.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"获取资源失败: {result.Error.Message}");
}
if (!result.Value.HasValue)
{
logger.Warn($"资源不存在: {resourceId}");
return NotFound($"资源 {resourceId} 不存在");
}
var resource = result.Value.Value;
logger.Info($"成功获取资源: {resourceId} ({resource.ResourceName})");
return File(resource.Data, resource.MimeType, resource.ResourceName);
}
catch (Exception ex)
{
logger.Error($"获取资源 {resourceId} 时出错: {ex.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"获取资源失败: {ex.Message}");
}
}
} }

View File

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

View File

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

View File

@@ -58,6 +58,10 @@ public class LogicAnalyzerController : ControllerBase
/// </summary> /// </summary>
public AnalyzerChannelDiv ChannelDiv { get; set; } = AnalyzerChannelDiv.EIGHT; public AnalyzerChannelDiv ChannelDiv { get; set; } = AnalyzerChannelDiv.EIGHT;
/// <summary> /// <summary>
/// 时钟分频系数
/// </summary>
public AnalyzerClockDiv ClockDiv { get; set; } = AnalyzerClockDiv.DIV1;
/// <summary>
/// 信号触发配置列表 /// 信号触发配置列表
/// </summary> /// </summary>
public SignalTriggerConfig[] SignalConfigs { get; set; } = Array.Empty<SignalTriggerConfig>(); public SignalTriggerConfig[] SignalConfigs { get; set; } = Array.Empty<SignalTriggerConfig>();
@@ -248,6 +252,7 @@ public class LogicAnalyzerController : ControllerBase
/// <param name="capture_length">深度</param> /// <param name="capture_length">深度</param>
/// <param name="pre_capture_length">预采样深度</param> /// <param name="pre_capture_length">预采样深度</param>
/// <param name="channel_div">有效通道(0-[1],1-[2],2-[4],3-[8],4-[16],5-[32])</param> /// <param name="channel_div">有效通道(0-[1],1-[2],2-[4],3-[8],4-[16],5-[32])</param>
/// <param name="clock_div">采样时钟分频系数</param>
/// <returns>操作结果</returns> /// <returns>操作结果</returns>
[HttpPost("SetCaptureParams")] [HttpPost("SetCaptureParams")]
[EnableCors("Users")] [EnableCors("Users")]
@@ -255,11 +260,12 @@ public class LogicAnalyzerController : ControllerBase
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)] [ProducesResponseType(StatusCodes.Status500InternalServerError)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> SetCaptureParams(int capture_length, int pre_capture_length, AnalyzerChannelDiv channel_div) public async Task<IActionResult> SetCaptureParams(int capture_length, int pre_capture_length, AnalyzerChannelDiv channel_div, AnalyzerClockDiv clock_div)
{ {
try try
{ {
if (capture_length < 0 || capture_length > 2048*32) //DDR深度为 32'h01000000 - 32'h0FFFFFFF
if (capture_length < 0 || capture_length > 0x10000000 - 0x01000000)
return BadRequest("采样深度设置错误"); return BadRequest("采样深度设置错误");
if (pre_capture_length < 0 || pre_capture_length >= capture_length) if (pre_capture_length < 0 || pre_capture_length >= capture_length)
return BadRequest("预采样深度必须小于捕获深度"); return BadRequest("预采样深度必须小于捕获深度");
@@ -268,18 +274,18 @@ public class LogicAnalyzerController : ControllerBase
if (analyzer == null) if (analyzer == null)
return BadRequest("用户未绑定有效的实验板"); return BadRequest("用户未绑定有效的实验板");
var result = await analyzer.SetCaptureParams(capture_length, pre_capture_length, channel_div); var result = await analyzer.SetCaptureParams(capture_length, pre_capture_length, channel_div, clock_div);
if (!result.IsSuccessful) if (!result.IsSuccessful)
{ {
logger.Error($"设置深度、预采样深度、有效通道失败: {result.Error}"); logger.Error($"设置深度、预采样深度、有效通道、时钟分频失败: {result.Error}");
return StatusCode(StatusCodes.Status500InternalServerError, "设置深度、预采样深度、有效通道失败"); return StatusCode(StatusCodes.Status500InternalServerError, "设置深度、预采样深度、有效通道、时钟分频失败");
} }
return Ok(result.Value); return Ok(result.Value);
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.Error(ex, "设置深度、预采样深度、有效通道失败时发生异常"); logger.Error(ex, "设置深度、预采样深度、有效通道、时钟分频失败时发生异常");
return StatusCode(StatusCodes.Status500InternalServerError, "操作失败,请稍后重试"); return StatusCode(StatusCodes.Status500InternalServerError, "操作失败,请稍后重试");
} }
} }
@@ -331,7 +337,7 @@ public class LogicAnalyzerController : ControllerBase
} }
// 设置深度、预采样深度、有效通道 // 设置深度、预采样深度、有效通道
var paramsResult = await analyzer.SetCaptureParams( var paramsResult = await analyzer.SetCaptureParams(
config.CaptureLength, config.PreCaptureLength, config.ChannelDiv); config.CaptureLength, config.PreCaptureLength, config.ChannelDiv, config.ClockDiv);
if (!paramsResult.IsSuccessful) if (!paramsResult.IsSuccessful)
{ {
logger.Error($"设置深度、预采样深度、有效通道失败: {paramsResult.Error}"); logger.Error($"设置深度、预采样深度、有效通道失败: {paramsResult.Error}");

View File

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

View File

@@ -229,9 +229,9 @@ public class Exam
} }
/// <summary> /// <summary>
/// 实验资源表(图片等) /// 资源类,统一管理实验资源、用户比特流等各类资源
/// </summary> /// </summary>
public class ExamResource public class Resource
{ {
/// <summary> /// <summary>
/// 资源的唯一标识符 /// 资源的唯一标识符
@@ -240,17 +240,29 @@ public class ExamResource
public int ID { get; set; } public int ID { get; set; }
/// <summary> /// <summary>
/// 所属实验ID /// 上传资源的用户ID
/// </summary> /// </summary>
[NotNull] [NotNull]
public required string ExamID { get; set; } public required Guid UserID { get; set; }
/// <summary> /// <summary>
/// 资源类型images, markdown, bitstream, diagram, project /// 所属实验ID可选如果不属于特定实验则为空
/// </summary>
[Nullable]
public string? ExamID { get; set; }
/// <summary>
/// 资源类型images, markdown, bitstream, diagram, project等
/// </summary> /// </summary>
[NotNull] [NotNull]
public required string ResourceType { get; set; } public required string ResourceType { get; set; }
/// <summary>
/// 资源用途template模板或 user用户上传
/// </summary>
[NotNull]
public required string ResourcePurpose { get; set; }
/// <summary> /// <summary>
/// 资源名称(包含文件扩展名) /// 资源名称(包含文件扩展名)
/// </summary> /// </summary>
@@ -264,10 +276,10 @@ public class ExamResource
public required byte[] Data { get; set; } public required byte[] Data { get; set; }
/// <summary> /// <summary>
/// 资源创建时间 /// 资源创建/上传时间
/// </summary> /// </summary>
[NotNull] [NotNull]
public DateTime CreatedTime { get; set; } = DateTime.Now; public DateTime UploadTime { get; set; } = DateTime.Now;
/// <summary> /// <summary>
/// 资源的MIME类型 /// 资源的MIME类型
@@ -305,6 +317,22 @@ public class ExamResource
/// </summary> /// </summary>
public const string Project = "project"; public const string Project = "project";
} }
/// <summary>
/// 资源用途枚举
/// </summary>
public static class ResourcePurposes
{
/// <summary>
/// 模板资源,通常由管理员上传,供用户参考
/// </summary>
public const string Template = "template";
/// <summary>
/// 用户上传的资源
/// </summary>
public const string User = "user";
}
} }
/// <summary> /// <summary>
@@ -355,7 +383,7 @@ public class AppDataConnection : DataConnection
this.CreateTable<User>(); this.CreateTable<User>();
this.CreateTable<Board>(); this.CreateTable<Board>();
this.CreateTable<Exam>(); this.CreateTable<Exam>();
this.CreateTable<ExamResource>(); this.CreateTable<Resource>();
logger.Info("数据库表创建完成"); logger.Info("数据库表创建完成");
} }
@@ -368,7 +396,7 @@ public class AppDataConnection : DataConnection
this.DropTable<User>(); this.DropTable<User>();
this.DropTable<Board>(); this.DropTable<Board>();
this.DropTable<Exam>(); this.DropTable<Exam>();
this.DropTable<ExamResource>(); this.DropTable<Resource>();
logger.Warn("所有数据库表已删除"); logger.Warn("所有数据库表已删除");
} }
@@ -828,9 +856,9 @@ public class AppDataConnection : DataConnection
public ITable<Exam> ExamTable => this.GetTable<Exam>(); public ITable<Exam> ExamTable => this.GetTable<Exam>();
/// <summary> /// <summary>
/// 实验资源表 /// 资源表(统一管理实验资源、用户比特流等)
/// </summary> /// </summary>
public ITable<ExamResource> ExamResourceTable => this.GetTable<ExamResource>(); public ITable<Resource> ResourceTable => this.GetTable<Resource>();
/// <summary> /// <summary>
/// 创建新实验 /// 创建新实验
@@ -933,34 +961,44 @@ public class AppDataConnection : DataConnection
} }
/// <summary> /// <summary>
/// 添加实验资源 /// 添加资源
/// </summary> /// </summary>
/// <param name="examId">所属实验ID</param> /// <param name="userId">上传用户ID</param>
/// <param name="resourceType">资源类型</param> /// <param name="resourceType">资源类型</param>
/// <param name="resourcePurpose">资源用途template 或 user</param>
/// <param name="resourceName">资源名称</param> /// <param name="resourceName">资源名称</param>
/// <param name="data">资源二进制数据</param> /// <param name="data">资源二进制数据</param>
/// <param name="examId">所属实验ID可选</param>
/// <param name="mimeType">MIME类型可选将根据文件扩展名自动确定</param> /// <param name="mimeType">MIME类型可选将根据文件扩展名自动确定</param>
/// <returns>创建的资源</returns> /// <returns>创建的资源</returns>
public Result<ExamResource> AddExamResource(string examId, string resourceType, string resourceName, byte[] data, string? mimeType = null) public Result<Resource> AddResource(Guid userId, string resourceType, string resourcePurpose, string resourceName, byte[] data, string? examId = null, string? mimeType = null)
{ {
try try
{ {
// 验证实验是否存在 // 验证用户是否存在
var exam = this.ExamTable.Where(e => e.ID == examId).FirstOrDefault(); var user = this.UserTable.Where(u => u.ID == userId).FirstOrDefault();
if (exam == null) if (user == null)
{ {
logger.Error($"实验不存在: {examId}"); logger.Error($"用户不存在: {userId}");
return new(new Exception($"实验不存在: {examId}")); return new(new Exception($"用户不存在: {userId}"));
} }
// 检查资源是否存在 // 如果指定了实验ID验证实验是否存在
var existingResource = this.ExamResourceTable if (!string.IsNullOrEmpty(examId))
.Where(r => r.ExamID == examId && r.ResourceType == resourceType && r.ResourceName == resourceName)
.FirstOrDefault();
if (existingResource != null)
{ {
logger.Error($"资源已存在: {examId}/{resourceType}/{resourceName}"); var exam = this.ExamTable.Where(e => e.ID == examId).FirstOrDefault();
return new(new Exception($"资源已存在: {examId}/{resourceType}/{resourceName}")); if (exam == null)
{
logger.Error($"实验不存在: {examId}");
return new(new Exception($"实验不存在: {examId}"));
}
}
// 验证资源用途
if (resourcePurpose != Resource.ResourcePurposes.Template && resourcePurpose != Resource.ResourcePurposes.User)
{
logger.Error($"无效的资源用途: {resourcePurpose}");
return new(new Exception($"无效的资源用途: {resourcePurpose}"));
} }
// 如果未指定MIME类型根据文件扩展名自动确定 // 如果未指定MIME类型根据文件扩展名自动确定
@@ -970,49 +1008,126 @@ public class AppDataConnection : DataConnection
mimeType = GetMimeTypeFromExtension(extension, resourceName); mimeType = GetMimeTypeFromExtension(extension, resourceName);
} }
var resource = new ExamResource var resource = new Resource
{ {
UserID = userId,
ExamID = examId, ExamID = examId,
ResourceType = resourceType, ResourceType = resourceType,
ResourcePurpose = resourcePurpose,
ResourceName = resourceName, ResourceName = resourceName,
Data = data, Data = data,
MimeType = mimeType, MimeType = mimeType,
CreatedTime = DateTime.Now UploadTime = DateTime.Now
}; };
this.Insert(resource); var insertedId = this.InsertWithIdentity(resource);
logger.Info($"新资源已添加: {examId}/{resourceType}/{resourceName} ({data.Length} bytes)"); resource.ID = Convert.ToInt32(insertedId);
logger.Info($"新资源已添加: {userId}/{resourceType}/{resourcePurpose}/{resourceName} ({data.Length} bytes)" +
(examId != null ? $" [实验: {examId}]" : "") + $" [ID: {resource.ID}]");
return new(resource); return new(resource);
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.Error($"添加实验资源时出错: {ex.Message}"); logger.Error($"添加资源时出错: {ex.Message}");
return new(ex); return new(ex);
} }
} }
/// <summary> /// <summary>
/// 获取指定实验ID的指定资源类型的所有资源的ID和名称 /// 获取资源信息列表(返回ID和名称
/// </summary>
/// <param name="examId">实验ID</param>
/// <param name="resourceType">资源类型</param> /// <param name="resourceType">资源类型</param>
/// <param name="examId">实验ID可选</param>
/// <param name="resourcePurpose">资源用途(可选)</param>
/// <param name="userId">用户ID可选</param>
/// </summary>
/// <returns>资源信息列表</returns> /// <returns>资源信息列表</returns>
public Result<(int ID, string Name)[]> GetExamResourceList(string examId, string resourceType) public Result<(int ID, string Name)[]> GetResourceList(string resourceType, string? examId = null, string? resourcePurpose = null, Guid? userId = null)
{ {
try try
{ {
var resources = this.ExamResourceTable var query = this.ResourceTable.Where(r => r.ResourceType == resourceType);
.Where(r => r.ExamID == examId && r.ResourceType == resourceType)
if (examId != null)
{
query = query.Where(r => r.ExamID == examId);
}
if (resourcePurpose != null)
{
query = query.Where(r => r.ResourcePurpose == resourcePurpose);
}
if (userId != null)
{
query = query.Where(r => r.UserID == userId);
}
var resources = query
.Select(r => new { r.ID, r.ResourceName }) .Select(r => new { r.ID, r.ResourceName })
.ToArray(); .ToArray();
var result = resources.Select(r => (r.ID, r.ResourceName)).ToArray(); var result = resources.Select(r => (r.ID, r.ResourceName)).ToArray();
logger.Info($"获取实验资源列表: {examId}/{resourceType},共 {result.Length} 个资源"); logger.Info($"获取资源列表: {resourceType}" +
(examId != null ? $"/{examId}" : "") +
(resourcePurpose != null ? $"/{resourcePurpose}" : "") +
(userId != null ? $"/{userId}" : "") +
$",共 {result.Length} 个资源");
return new(result); return new(result);
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.Error($"获取实验资源列表时出错: {ex.Message}"); logger.Error($"获取资源列表时出错: {ex.Message}");
return new(ex);
}
}
/// <summary>
/// 获取完整的资源列表
/// </summary>
/// <param name="examId">实验ID可选</param>
/// <param name="resourceType">资源类型(可选)</param>
/// <param name="resourcePurpose">资源用途(可选)</param>
/// <param name="userId">用户ID可选</param>
/// <returns>完整的资源对象列表</returns>
public Result<List<Resource>> GetFullResourceList(string? examId = null, string? resourceType = null, string? resourcePurpose = null, Guid? userId = null)
{
try
{
var query = this.ResourceTable.AsQueryable();
if (examId != null)
{
query = query.Where(r => r.ExamID == examId);
}
if (resourceType != null)
{
query = query.Where(r => r.ResourceType == resourceType);
}
if (resourcePurpose != null)
{
query = query.Where(r => r.ResourcePurpose == resourcePurpose);
}
if (userId != null)
{
query = query.Where(r => r.UserID == userId);
}
var resources = query.OrderByDescending(r => r.UploadTime).ToList();
logger.Info($"获取完整资源列表" +
(examId != null ? $" [实验: {examId}]" : "") +
(resourceType != null ? $" [类型: {resourceType}]" : "") +
(resourcePurpose != null ? $" [用途: {resourcePurpose}]" : "") +
(userId != null ? $" [用户: {userId}]" : "") +
$",共 {resources.Count} 个资源");
return new(resources);
}
catch (Exception ex)
{
logger.Error($"获取完整资源列表时出错: {ex.Message}");
return new(ex); return new(ex);
} }
} }
@@ -1022,16 +1137,16 @@ public class AppDataConnection : DataConnection
/// </summary> /// </summary>
/// <param name="resourceId">资源ID</param> /// <param name="resourceId">资源ID</param>
/// <returns>资源数据</returns> /// <returns>资源数据</returns>
public Result<Optional<ExamResource>> GetExamResourceById(int resourceId) public Result<Optional<Resource>> GetResourceById(int resourceId)
{ {
try try
{ {
var resource = this.ExamResourceTable.Where(r => r.ID == resourceId).FirstOrDefault(); var resource = this.ResourceTable.Where(r => r.ID == resourceId).FirstOrDefault();
if (resource == null) if (resource == null)
{ {
logger.Info($"未找到资源: {resourceId}"); logger.Info($"未找到资源: {resourceId}");
return new(Optional<ExamResource>.None); return new(Optional<Resource>.None);
} }
logger.Info($"成功获取资源: {resourceId} ({resource.ResourceName})"); logger.Info($"成功获取资源: {resourceId} ({resource.ResourceName})");
@@ -1045,15 +1160,15 @@ public class AppDataConnection : DataConnection
} }
/// <summary> /// <summary>
/// 删除实验资源 /// 删除资源
/// </summary> /// </summary>
/// <param name="resourceId">资源ID</param> /// <param name="resourceId">资源ID</param>
/// <returns>删除的记录数</returns> /// <returns>删除的记录数</returns>
public Result<int> DeleteExamResource(int resourceId) public Result<int> DeleteResource(int resourceId)
{ {
try try
{ {
var result = this.ExamResourceTable.Where(r => r.ID == resourceId).Delete(); var result = this.ResourceTable.Where(r => r.ID == resourceId).Delete();
logger.Info($"资源已删除: {resourceId},删除记录数: {result}"); logger.Info($"资源已删除: {resourceId},删除记录数: {result}");
return new(result); return new(result);
} }
@@ -1132,29 +1247,20 @@ public class AppDataConnection : DataConnection
} }
/// <summary> /// <summary>
/// 删除所有实验 /// 根据文件扩展名获取比特流MIME类型
/// </summary> /// </summary>
/// <returns>删除的实验数量</returns> /// <param name="extension">文件扩展名</param>
public int DeleteAllExams() /// <returns>MIME类型</returns>
private string GetBitstreamMimeType(string extension)
{ {
// 先删除所有实验资源 return extension.ToLowerInvariant() switch
var resourceDeleteCount = this.DeleteAllExamResources(); {
logger.Info($"已删除所有实验资源,共删除 {resourceDeleteCount} 个资源"); ".bit" => "application/octet-stream",
".sbit" => "application/octet-stream",
// 再删除所有实验 ".bin" => "application/octet-stream",
var examDeleteCount = this.ExamTable.Delete(); ".mcs" => "application/octet-stream",
logger.Info($"已删除所有实验,共删除 {examDeleteCount} 个实验"); ".hex" => "text/plain",
return examDeleteCount; _ => "application/octet-stream"
} };
/// <summary>
/// 删除所有实验资源
/// </summary>
/// <returns>删除的资源数量</returns>
public int DeleteAllExamResources()
{
var deleteCount = this.ExamResourceTable.Delete();
logger.Info($"已删除所有实验资源,共删除 {deleteCount} 个资源");
return deleteCount;
} }
} }

View File

@@ -1,6 +1,5 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims; using System.Security.Claims;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using DotNext; using DotNext;
@@ -8,7 +7,7 @@ using System.Collections.Concurrent;
using TypedSignalR.Client; using TypedSignalR.Client;
using Tapper; using Tapper;
namespace server.Hubs.JtagHub; namespace server.Hubs;
[Hub] [Hub]
public interface IJtagHub public interface IJtagHub

View File

@@ -0,0 +1,61 @@
using Microsoft.AspNetCore.Authorization;
using System.Security.Claims;
using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.Cors;
using TypedSignalR.Client;
using Tapper;
using server.Services;
namespace server.Hubs;
[Hub]
public interface IProgressHub
{
Task<bool> Join(string taskId);
}
[Receiver]
public interface IProgressReceiver
{
Task OnReceiveProgress(ProgressInfo message);
}
[TranspilationSource]
public enum ProgressStatus
{
Pending,
InProgress,
Completed,
Canceled,
Failed
}
[TranspilationSource]
public class ProgressInfo
{
public string TaskId { get; }
public ProgressStatus Status { get; }
public int ProgressPercent { get; }
public string ErrorMessage { get; }
};
[Authorize]
[EnableCors("SignalR")]
public class ProgressHub : Hub<IProgressReceiver>, IProgressHub
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly IHubContext<ProgressHub, IProgressReceiver> _hubContext;
private readonly ProgressTrackerService _tracker;
public ProgressHub(IHubContext<ProgressHub, IProgressReceiver> hubContext, ProgressTrackerService tracker)
{
_hubContext = hubContext;
_tracker = tracker;
}
public async Task<bool> Join(string taskId)
{
return _tracker.BindTask(taskId, Context.ConnectionId);
}
}

View File

@@ -1,6 +1,7 @@
using System.Net; using System.Net;
using DotNext; using DotNext;
using Peripherals.PowerClient; using Peripherals.PowerClient;
using WebProtocol;
namespace Peripherals.CameraClient; namespace Peripherals.CameraClient;
@@ -19,7 +20,7 @@ class Camera
{ {
private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger(); private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
readonly int timeout = 2000; readonly int timeout = 500;
readonly int taskID; readonly int taskID;
readonly int port; readonly int port;
readonly string address; readonly string address;
@@ -43,7 +44,7 @@ class Camera
/// <param name="address">摄像头设备IP地址</param> /// <param name="address">摄像头设备IP地址</param>
/// <param name="port">摄像头设备端口</param> /// <param name="port">摄像头设备端口</param>
/// <param name="timeout">超时时间(毫秒)</param> /// <param name="timeout">超时时间(毫秒)</param>
public Camera(string address, int port, int timeout = 2000) public Camera(string address, int port, int timeout = 500)
{ {
if (timeout < 0) if (timeout < 0)
throw new ArgumentException("Timeout couldn't be negative", nameof(timeout)); throw new ArgumentException("Timeout couldn't be negative", nameof(timeout));
@@ -225,6 +226,7 @@ class Camera
this.taskID, // taskID this.taskID, // taskID
FrameAddr, FrameAddr,
(int)_currentFrameLength, // 使用当前分辨率的动态大小 (int)_currentFrameLength, // 使用当前分辨率的动态大小
BurstType.ExtendBurst,
this.timeout); this.timeout);
if (!result.IsSuccessful) if (!result.IsSuccessful)
@@ -462,6 +464,20 @@ class Camera
); );
} }
/// <summary>
/// 配置为960x540分辨率
/// </summary>
/// <returns>配置结果</returns>
public async ValueTask<Result<bool>> ConfigureResolution960x540()
{
return await ConfigureResolution(
hStart: 0, vStart: 0,
dvpHo: 960, dvpVo: 540,
hts: 1700, vts: 1500,
hOffset: 16, vOffset: 4
);
}
/// <summary> /// <summary>
/// 配置为320x240分辨率 /// 配置为320x240分辨率
/// </summary> /// </summary>
@@ -543,6 +559,9 @@ class Camera
case "640x480": case "640x480":
result = await ConfigureResolution640x480(); result = await ConfigureResolution640x480();
break; break;
case "960x540":
result = await ConfigureResolution960x540();
break;
case "1280x720": case "1280x720":
result = await ConfigureResolution1280x720(); result = await ConfigureResolution1280x720();
break; break;

View File

@@ -0,0 +1,120 @@
using System.Net;
using DotNext;
using Peripherals.PowerClient;
using WebProtocol;
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;
}
class HdmiIn
{
private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
readonly int timeout = 500;
readonly int taskID;
readonly int port;
readonly string address;
private IPEndPoint ep;
// 动态分辨率参数
private UInt16 _currentWidth = 960;
private UInt16 _currentHeight = 540;
private UInt32 _currentFrameLength = 960 * 540 * 2 / 4; // RGB565格式2字节/像素按4字节对齐
/// <summary>
/// 初始化HDMI输入客户端
/// </summary>
/// <param name="address">HDMI输入设备IP地址</param>
/// <param name="port">HDMI输入设备端口</param>
/// <param name="taskID">任务ID</param>
/// <param name="timeout">超时时间(毫秒)</param>
public HdmiIn(string address, int port, int taskID, int timeout = 500)
{
if (timeout < 0)
throw new ArgumentException("Timeout couldn't be negative", nameof(timeout));
this.address = address;
this.port = port;
this.ep = new IPEndPoint(IPAddress.Parse(address), port);
this.taskID = taskID;
this.timeout = timeout;
}
public async ValueTask<Result<bool>> EnableTrans(bool isEnable)
{
var ret = await UDPClientPool.WriteAddr(this.ep, this.taskID, HdmiInAddr.HdmiIn_CTRL, (isEnable ? 0x00000001u : 0x00000000u));
if (!ret.IsSuccessful)
{
logger.Error($"Failed to write HdmiIn_CTRL to HdmiIn at {this.address}:{this.port}, error: {ret.Error}");
return new(ret.Error);
}
if (!ret.Value)
{
logger.Error($"HdmiIn_CTRL write returned false for HdmiIn at {this.address}:{this.port}");
return false;
}
return true;
}
/// <summary>
/// 读取一帧图像数据
/// </summary>
/// <returns>包含图像数据的字节数组</returns>
public async ValueTask<Result<byte[]>> ReadFrame()
{
// 只在第一次或出错时清除UDP缓冲区避免每帧都清除造成延迟
// MsgBus.UDPServer.ClearUDPData(this.address, this.taskID);
logger.Trace($"Reading frame from HdmiIn {this.address}");
// 使用UDPClientPool读取图像帧数据
var result = await UDPClientPool.ReadAddr4BytesAsync(
this.ep,
this.taskID, // taskID
HdmiInAddr.HdmiIn_READFIFO,
(int)_currentFrameLength, // 使用当前分辨率的动态大小
BurstType.FixedBurst,
this.timeout);
if (!result.IsSuccessful)
{
logger.Error($"Failed to read frame from HdmiIn {this.address}:{this.port}, error: {result.Error}");
// 读取失败时清除缓冲区,为下次读取做准备
try
{
MsgBus.UDPServer.ClearUDPData(this.address, this.taskID);
}
catch (Exception ex)
{
logger.Warn($"Failed to clear UDP data after read error: {ex.Message}");
}
return new(result.Error);
}
logger.Trace($"Successfully read frame from HdmiIn {this.address}:{this.port}, data length: {result.Value.Length} bytes");
return result.Value;
}
/// <summary>
/// 获取当前分辨率
/// </summary>
/// <returns>当前分辨率(宽度, 高度)</returns>
public (int Width, int Height) GetCurrentResolution()
{
return (_currentWidth, _currentHeight);
}
/// <summary>
/// 获取当前帧长度
/// </summary>
/// <returns>当前帧长度</returns>
public UInt32 GetCurrentFrameLength()
{
return _currentFrameLength;
}
}

View File

@@ -2,7 +2,7 @@ using System.Collections;
using System.Net; using System.Net;
using DotNext; using DotNext;
using Newtonsoft.Json; using Newtonsoft.Json;
using server; using server.Services;
using WebProtocol; using WebProtocol;
namespace Peripherals.JtagClient; namespace Peripherals.JtagClient;
@@ -442,11 +442,12 @@ public class Jtag
return Convert.ToUInt32(Common.Number.BytesToUInt32(retPackOpts.Data).Value); return Convert.ToUInt32(Common.Number.BytesToUInt32(retPackOpts.Data).Value);
} }
async ValueTask<Result<bool>> WriteFIFO async ValueTask<Result<bool>> WriteFIFO(
(UInt32 devAddr, UInt32 data, UInt32 result, UInt32 resultMask = 0xFF_FF_FF_FF, UInt32 delayMilliseconds = 0) UInt32 devAddr, UInt32 data, UInt32 result,
UInt32 resultMask = 0xFF_FF_FF_FF, UInt32 delayMilliseconds = 0, ProgressReporter? progress = null)
{ {
{ {
var ret = await UDPClientPool.WriteAddr(this.ep, 0, devAddr, data, this.timeout); var ret = await UDPClientPool.WriteAddr(this.ep, 0, devAddr, data, this.timeout, progress?.CreateChild(80));
if (!ret.IsSuccessful) return new(ret.Error); if (!ret.IsSuccessful) return new(ret.Error);
if (!ret.Value) return new(new Exception("Write FIFO failed")); if (!ret.Value) return new(new Exception("Write FIFO failed"));
} }
@@ -457,15 +458,17 @@ public class Jtag
{ {
var ret = await UDPClientPool.ReadAddrWithWait(this.ep, 0, JtagAddr.STATE, result, resultMask, 0, this.timeout); var ret = await UDPClientPool.ReadAddrWithWait(this.ep, 0, JtagAddr.STATE, result, resultMask, 0, this.timeout);
if (!ret.IsSuccessful) return new(ret.Error); if (!ret.IsSuccessful) return new(ret.Error);
progress?.Finish();
return ret.Value; return ret.Value;
} }
} }
async ValueTask<Result<bool>> WriteFIFO async ValueTask<Result<bool>> WriteFIFO(
(UInt32 devAddr, byte[] data, UInt32 result, UInt32 resultMask = 0xFF_FF_FF_FF, UInt32 delayMilliseconds = 0) UInt32 devAddr, byte[] data, UInt32 result,
UInt32 resultMask = 0xFF_FF_FF_FF, UInt32 delayMilliseconds = 0, ProgressReporter? progress = null)
{ {
{ {
var ret = await UDPClientPool.WriteAddr(this.ep, 0, devAddr, data, this.timeout); var ret = await UDPClientPool.WriteAddr(this.ep, 0, devAddr, data, this.timeout, progress?.CreateChild(80));
if (!ret.IsSuccessful) return new(ret.Error); if (!ret.IsSuccessful) return new(ret.Error);
if (!ret.Value) return new(new Exception("Write FIFO failed")); if (!ret.Value) return new(new Exception("Write FIFO failed"));
} }
@@ -476,6 +479,7 @@ public class Jtag
{ {
var ret = await UDPClientPool.ReadAddrWithWait(this.ep, 0, JtagAddr.STATE, result, resultMask, 0, this.timeout); var ret = await UDPClientPool.ReadAddrWithWait(this.ep, 0, JtagAddr.STATE, result, resultMask, 0, this.timeout);
if (!ret.IsSuccessful) return new(ret.Error); if (!ret.IsSuccessful) return new(ret.Error);
progress?.Finish();
return ret.Value; return ret.Value;
} }
} }
@@ -559,7 +563,8 @@ public class Jtag
return await ClearWriteDataReg(); return await ClearWriteDataReg();
} }
async ValueTask<Result<bool>> LoadDRCareInput(byte[] bytesArray, UInt32 timeout = 10_000, UInt32 cycle = 500) async ValueTask<Result<bool>> LoadDRCareInput(
byte[] bytesArray, UInt32 timeout = 10_000, UInt32 cycle = 500, ProgressReporter? progress = null)
{ {
var bytesLen = ((uint)(bytesArray.Length * 8)); var bytesLen = ((uint)(bytesArray.Length * 8));
if (bytesLen > Math.Pow(2, 28)) return new(new Exception("Length is over 2^(28 - 3)")); if (bytesLen > Math.Pow(2, 28)) return new(new Exception("Length is over 2^(28 - 3)"));
@@ -574,11 +579,15 @@ public class Jtag
else if (!ret.Value) return new(new Exception("Write CMD_JTAG_LOAD_DR_CAREI Failed")); else if (!ret.Value) return new(new Exception("Write CMD_JTAG_LOAD_DR_CAREI Failed"));
} }
progress?.Report(10);
{ {
var ret = await WriteFIFO( var ret = await WriteFIFO(
JtagAddr.WRITE_DATA, JtagAddr.WRITE_DATA,
bytesArray, 0x01_00_00_00, bytesArray, 0x01_00_00_00,
JtagState.CMD_EXEC_FINISH); JtagState.CMD_EXEC_FINISH,
progress: progress?.CreateChild(90)
);
if (!ret.IsSuccessful) return new(ret.Error); if (!ret.IsSuccessful) return new(ret.Error);
return ret.Value; return ret.Value;
@@ -612,13 +621,10 @@ public class Jtag
if (ret.Value) if (ret.Value)
{ {
var array = new UInt32[UInt32Num]; var array = new UInt32[UInt32Num];
for (int i = 0; i < UInt32Num; i++) var retData = await UDPClientPool.ReadAddr4Bytes(ep, 0, JtagAddr.READ_DATA, (int)UInt32Num);
{ if (!retData.IsSuccessful)
var retData = await ReadFIFO(JtagAddr.READ_DATA); return new(new Exception("Read FIFO failed when Load DR"));
if (!retData.IsSuccessful) Buffer.BlockCopy(retData.Value, 0, array, 0, (int)UInt32Num * 4);
return new(new Exception("Read FIFO failed when Load DR"));
array[i] = retData.Value;
}
return array; return array;
} }
else else
@@ -704,44 +710,55 @@ public class Jtag
/// </summary> /// </summary>
/// <param name="bitstream">比特流数据</param> /// <param name="bitstream">比特流数据</param>
/// <returns>指示下载是否成功的异步结果</returns> /// <returns>指示下载是否成功的异步结果</returns>
public async ValueTask<Result<bool>> DownloadBitstream(byte[] bitstream) public async ValueTask<Result<bool>> DownloadBitstream(byte[] bitstream, ProgressReporter? progress = null)
{ {
// Clear Data // Clear Data
MsgBus.UDPServer.ClearUDPData(this.address, 0); MsgBus.UDPServer.ClearUDPData(this.address, 0);
logger.Trace($"Clear up udp server {this.address,0} receive data"); logger.Trace($"Clear up udp server {this.address,0} receive data");
if (progress != null)
{
progress.ExpectedSteps = 25;
progress.Increase();
}
Result<bool> ret; Result<bool> ret;
ret = await CloseTest(); ret = await CloseTest();
if (!ret.IsSuccessful) return new(ret.Error); if (!ret.IsSuccessful) return new(ret.Error);
else if (!ret.Value) return new(new Exception("Jtag Close Test Failed")); else if (!ret.Value) return new(new Exception("Jtag Close Test Failed"));
progress?.Increase();
ret = await RunTest(); ret = await RunTest();
if (!ret.IsSuccessful) return new(ret.Error); if (!ret.IsSuccessful) return new(ret.Error);
else if (!ret.Value) return new(new Exception("Jtag Run Test Failed")); else if (!ret.Value) return new(new Exception("Jtag Run Test Failed"));
progress?.Increase();
logger.Trace("Jtag initialize"); logger.Trace("Jtag initialize");
ret = await ExecRDCmd(JtagCmd.JTAG_DR_JRST); ret = await ExecRDCmd(JtagCmd.JTAG_DR_JRST);
if (!ret.IsSuccessful) return new(ret.Error); if (!ret.IsSuccessful) return new(ret.Error);
else if (!ret.Value) return new(new Exception("Jtag Execute Command JTAG_DR_JRST Failed")); else if (!ret.Value) return new(new Exception("Jtag Execute Command JTAG_DR_JRST Failed"));
progress?.Increase();
ret = await RunTest(); ret = await RunTest();
if (!ret.IsSuccessful) return new(ret.Error); if (!ret.IsSuccessful) return new(ret.Error);
else if (!ret.Value) return new(new Exception("Jtag Run Test Failed")); else if (!ret.Value) return new(new Exception("Jtag Run Test Failed"));
progress?.Increase();
ret = await ExecRDCmd(JtagCmd.JTAG_DR_CFGI); ret = await ExecRDCmd(JtagCmd.JTAG_DR_CFGI);
if (!ret.IsSuccessful) return new(ret.Error); if (!ret.IsSuccessful) return new(ret.Error);
else if (!ret.Value) return new(new Exception("Jtag Execute Command JTAG_DR_CFGI Failed")); else if (!ret.Value) return new(new Exception("Jtag Execute Command JTAG_DR_CFGI Failed"));
progress?.Increase();
logger.Trace("Jtag ready to write bitstream"); logger.Trace("Jtag ready to write bitstream");
ret = await IdleDelay(100000); ret = await IdleDelay(100000);
if (!ret.IsSuccessful) return new(ret.Error); if (!ret.IsSuccessful) return new(ret.Error);
else if (!ret.Value) return new(new Exception("Jtag IDLE Delay Failed")); else if (!ret.Value) return new(new Exception("Jtag IDLE Delay Failed"));
progress?.Increase();
ret = await LoadDRCareInput(bitstream); ret = await LoadDRCareInput(bitstream, progress: progress?.CreateChild(50));
if (!ret.IsSuccessful) return new(ret.Error); if (!ret.IsSuccessful) return new(ret.Error);
else if (!ret.Value) return new(new Exception("Jtag Load Data Failed")); else if (!ret.Value) return new(new Exception("Jtag Load Data Failed"));
@@ -750,32 +767,40 @@ public class Jtag
ret = await CloseTest(); ret = await CloseTest();
if (!ret.IsSuccessful) return new(ret.Error); if (!ret.IsSuccessful) return new(ret.Error);
else if (!ret.Value) return new(new Exception("Jtag Close Test Failed")); else if (!ret.Value) return new(new Exception("Jtag Close Test Failed"));
progress?.Increase();
ret = await RunTest(); ret = await RunTest();
if (!ret.IsSuccessful) return new(ret.Error); if (!ret.IsSuccessful) return new(ret.Error);
else if (!ret.Value) return new(new Exception("Jtag Run Test Failed")); else if (!ret.Value) return new(new Exception("Jtag Run Test Failed"));
progress?.Increase();
ret = await ExecRDCmd(JtagCmd.JTAG_DR_JWAKEUP); ret = await ExecRDCmd(JtagCmd.JTAG_DR_JWAKEUP);
if (!ret.IsSuccessful) return new(ret.Error); if (!ret.IsSuccessful) return new(ret.Error);
else if (!ret.Value) return new(new Exception("Jtag Execute Command JTAG_DR_JWAKEUP Failed")); else if (!ret.Value) return new(new Exception("Jtag Execute Command JTAG_DR_JWAKEUP Failed"));
progress?.Increase();
logger.Trace("Jtag reset device"); logger.Trace("Jtag reset device");
ret = await IdleDelay(10000); ret = await IdleDelay(10000);
if (!ret.IsSuccessful) return new(ret.Error); if (!ret.IsSuccessful) return new(ret.Error);
else if (!ret.Value) return new(new Exception("Jtag IDLE Delay Failed")); else if (!ret.Value) return new(new Exception("Jtag IDLE Delay Failed"));
progress?.Increase();
var retCode = await ReadStatusReg(); var retCode = await ReadStatusReg();
if (!retCode.IsSuccessful) return new(retCode.Error); if (!retCode.IsSuccessful) return new(retCode.Error);
var jtagStatus = new JtagStatusReg(retCode.Value); var jtagStatus = new JtagStatusReg(retCode.Value);
if (!(jtagStatus.done && jtagStatus.wakeup_over && jtagStatus.init_complete)) if (!(jtagStatus.done && jtagStatus.wakeup_over && jtagStatus.init_complete))
return new(new Exception("Jtag download bitstream failed")); return new(new Exception("Jtag download bitstream failed"));
progress?.Increase();
ret = await CloseTest(); ret = await CloseTest();
if (!ret.IsSuccessful) return new(ret.Error); if (!ret.IsSuccessful) return new(ret.Error);
else if (!ret.Value) return new(new Exception("Jtag Close Test Failed")); else if (!ret.Value) return new(new Exception("Jtag Close Test Failed"));
logger.Trace("Jtag download bitstream successfully"); logger.Trace("Jtag download bitstream successfully");
progress?.Increase();
// Finish
progress?.Finish();
return true; return true;
} }
@@ -788,7 +813,7 @@ public class Jtag
{ {
var paser = new BsdlParser.Parser(); var paser = new BsdlParser.Parser();
var portNum = paser.GetBoundaryRegsNum().Value; var portNum = paser.GetBoundaryRegsNum().Value;
logger.Debug($"Get boundar scan registers number: {portNum}"); logger.Debug($"Get boundary scan registers number: {portNum}");
// Clear Data // Clear Data
MsgBus.UDPServer.ClearUDPData(this.address, 0); MsgBus.UDPServer.ClearUDPData(this.address, 0);

View File

@@ -2,6 +2,7 @@ using System.Collections;
using System.Net; using System.Net;
using Common; using Common;
using DotNext; using DotNext;
using WebProtocol;
namespace Peripherals.LogicAnalyzerClient; namespace Peripherals.LogicAnalyzerClient;
@@ -66,10 +67,11 @@ static class AnalyzerAddr
public const UInt32 LOAD_NUM_ADDR = BASE + 0x0000_0002; public const UInt32 LOAD_NUM_ADDR = BASE + 0x0000_0002;
public const UInt32 PRE_LOAD_NUM_ADDR = BASE + 0x0000_0003; public const UInt32 PRE_LOAD_NUM_ADDR = BASE + 0x0000_0003;
public const UInt32 CAHNNEL_DIV_ADDR = BASE + 0x0000_0004; 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_START_WRITE_ADDR = DMA1_BASE + 0x0000_0012;
public const UInt32 DMA1_END_WRITE_ADDR = DMA1_BASE + 0x0000_0013; 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 DMA1_CAPTURE_CTRL_ADDR = DMA1_BASE + 0x0000_0014;
public const UInt32 STORE_OFFSET_ADDR = DDR_BASE + 0x0010_0000; public const UInt32 STORE_OFFSET_ADDR = DDR_BASE + 0x0100_0000;
/// <summary> /// <summary>
/// 0x0100_0000 - 0x0100_03FF 只读 32位波形存储得到的32位数据中低八位最先捕获高八位最后捕获。<br/> /// 0x0100_0000 - 0x0100_03FF 只读 32位波形存储得到的32位数据中低八位最先捕获高八位最后捕获。<br/>
@@ -137,6 +139,52 @@ public enum GlobalCaptureMode
NOR = 0b11 NOR = 0b11
} }
/// <summary>
/// 逻辑分析仪采样时钟分频系数
/// </summary>
public enum AnalyzerClockDiv
{
/// <summary>
/// 1分频
/// </summary>
DIV1 = 0x0000_0000,
/// <summary>
/// 2分频
/// </summary>
DIV2 = 0x0000_0001,
/// <summary>
/// 4分频
/// </summary>
DIV4 = 0x0000_0002,
/// <summary>
/// 8分频
/// </summary>
DIV8 = 0x0000_0003,
/// <summary>
/// 16分频
/// </summary>
DIV16 = 0x0000_0004,
/// <summary>
/// 32分频
/// </summary>
DIV32 = 0x0000_0005,
/// <summary>
/// 64分频
/// </summary>
DIV64 = 0x0000_0006,
/// <summary>
/// 128分频
/// </summary>
DIV128 = 0x0000_0007
}
/// <summary> /// <summary>
/// 信号M的操作符枚举 /// 信号M的操作符枚举
/// </summary> /// </summary>
@@ -386,13 +434,14 @@ public class Analyzer
} }
/// <summary> /// <summary>
/// 设置逻辑分析仪的深度、预采样深度、有效通道 /// 设置逻辑分析仪的深度、预采样深度、有效通道、分频系数
/// </summary> /// </summary>
/// <param name="capture_length">深度</param> /// <param name="capture_length">深度</param>
/// <param name="pre_capture_length">预采样深度</param> /// <param name="pre_capture_length">预采样深度</param>
/// <param name="channel_div">有效通道(0-[1],1-[2],2-[4],3-[8],4-[16],5-[32])</param> /// <param name="channel_div">有效通道(0-[1],1-[2],2-[4],3-[8],4-[16],5-[32])</param>
/// <param name="clock_div">采样时钟分频系数</param>
/// <returns>操作结果成功返回true否则返回异常信息</returns> /// <returns>操作结果成功返回true否则返回异常信息</returns>
public async ValueTask<Result<bool>> SetCaptureParams(int capture_length, int pre_capture_length, AnalyzerChannelDiv channel_div) public async ValueTask<Result<bool>> SetCaptureParams(int capture_length, int pre_capture_length, AnalyzerChannelDiv channel_div, AnalyzerClockDiv clock_div)
{ {
if (capture_length == 0) capture_length = 1; if (capture_length == 0) capture_length = 1;
if (pre_capture_length == 0) pre_capture_length = 1; if (pre_capture_length == 0) pre_capture_length = 1;
@@ -461,6 +510,19 @@ public class Analyzer
return new(new Exception("Failed to set CAHNNEL_DIV_ADDR")); return new(new Exception("Failed to set CAHNNEL_DIV_ADDR"));
} }
} }
{
var ret = await UDPClientPool.WriteAddr(this.ep, this.taskID, AnalyzerAddr.CLOCK_DIV_ADDR, (UInt32)clock_div, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to set CLOCK_DIV_ADDR: {ret.Error}");
return new(ret.Error);
}
if (!ret.Value)
{
logger.Error("WriteAddr to CLOCK_DIV_ADDR returned false");
return new(new Exception("Failed to set CLOCK_DIV_ADDR"));
}
}
return true; return true;
} }
@@ -475,6 +537,7 @@ public class Analyzer
this.taskID, this.taskID,
AnalyzerAddr.STORE_OFFSET_ADDR, AnalyzerAddr.STORE_OFFSET_ADDR,
capture_length, capture_length,
BurstType.ExtendBurst, // 使用扩展突发读取
this.timeout this.timeout
); );
if (!ret.IsSuccessful) if (!ret.IsSuccessful)

View File

@@ -1,6 +1,7 @@
using System.Net; using System.Net;
using Common; using Common;
using DotNext; using DotNext;
using WebProtocol;
namespace Peripherals.OscilloscopeClient; namespace Peripherals.OscilloscopeClient;
@@ -319,6 +320,7 @@ class Oscilloscope
this.taskID, this.taskID,
OscilloscopeAddr.RD_DATA_ADDR, OscilloscopeAddr.RD_DATA_ADDR,
(int)OscilloscopeAddr.RD_DATA_LENGTH / 32, (int)OscilloscopeAddr.RD_DATA_LENGTH / 32,
BurstType.ExtendBurst, // 使用扩展突发读取
this.timeout this.timeout
); );
if (!ret.IsSuccessful) if (!ret.IsSuccessful)

View File

@@ -0,0 +1,498 @@
using System.Net;
using System.Collections.Concurrent;
using Peripherals.HdmiInClient;
namespace server.Services;
public class HdmiVideoStreamEndpoint
{
public string BoardId { get; set; } = "";
public string MjpegUrl { get; set; } = "";
public string VideoUrl { get; set; } = "";
public string SnapshotUrl { get; set; } = "";
}
public class HttpHdmiVideoStreamService : BackgroundService
{
private readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private HttpListener? _httpListener;
private readonly int _serverPort = 4322;
private readonly ConcurrentDictionary<string, HdmiIn> _hdmiInDict = new();
private readonly ConcurrentDictionary<string, CancellationTokenSource> _hdmiInCtsDict = new();
public override async Task StartAsync(CancellationToken cancellationToken)
{
_httpListener = new HttpListener();
_httpListener.Prefixes.Add($"http://*:{_serverPort}/");
_httpListener.Start();
logger.Info($"HDMI Video Stream Service started on port {_serverPort}");
await base.StartAsync(cancellationToken);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
while (!stoppingToken.IsCancellationRequested)
{
HttpListenerContext? context = null;
try
{
logger.Debug("Waiting for HTTP request...");
context = await _httpListener.GetContextAsync();
logger.Debug($"Received request: {context.Request.Url?.AbsolutePath}");
}
catch (ObjectDisposedException)
{
// Listener closed, exit loop
break;
}
catch (HttpListenerException)
{
// Listener closed, exit loop
break;
}
catch (Exception ex)
{
logger.Error(ex, "Error in GetContextAsync");
break;
}
if (context != null)
_ = HandleRequestAsync(context, stoppingToken);
}
}
finally
{
_httpListener?.Close();
logger.Info("HDMI Video Stream Service stopped.");
}
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
logger.Info("Stopping HDMI Video Stream Service...");
// 禁用所有活跃的HDMI传输
var disableTasks = new List<Task>();
foreach (var hdmiKey in _hdmiInDict.Keys)
{
disableTasks.Add(DisableHdmiTransmissionAsync(hdmiKey));
}
// 等待所有禁用操作完成
await Task.WhenAll(disableTasks);
// 清空字典
_hdmiInDict.Clear();
_hdmiInCtsDict.Clear();
_httpListener?.Close(); // 立即关闭监听器,唤醒阻塞
await base.StopAsync(cancellationToken);
}
public async Task DisableHdmiTransmissionAsync(string key)
{
try
{
var cts = _hdmiInCtsDict[key];
cts.Cancel();
var hdmiIn = _hdmiInDict[key];
var disableResult = await hdmiIn.EnableTrans(false);
if (disableResult.IsSuccessful)
{
logger.Info("Successfully disabled HDMI transmission");
}
else
{
logger.Error($"Failed to disable HDMI transmission: {disableResult.Error}");
}
}
catch (Exception ex)
{
logger.Error(ex, "Exception occurred while disabling HDMI transmission");
}
}
// 获取/创建 HdmiIn 实例
private async Task<HdmiIn?> GetOrCreateHdmiInAsync(string boardId)
{
if (_hdmiInDict.TryGetValue(boardId, out var hdmiIn))
{
try
{
var enableResult = await hdmiIn.EnableTrans(true);
if (!enableResult.IsSuccessful)
{
logger.Error($"Failed to enable HDMI transmission for board {boardId}: {enableResult.Error}");
return null;
}
logger.Info($"Successfully enabled HDMI transmission for board {boardId}");
}
catch (Exception ex)
{
logger.Error(ex, $"Exception occurred while enabling HDMI transmission for board {boardId}");
return null;
}
_hdmiInDict[boardId] = hdmiIn;
_hdmiInCtsDict[boardId] = new CancellationTokenSource();
return hdmiIn;
}
var db = new Database.AppDataConnection();
if (db == null)
{
logger.Error("Failed to create HdmiIn instance");
return null;
}
var boardRet = db.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;
hdmiIn = new HdmiIn(board.IpAddr, board.Port, 0); // taskID 可根据实际需求调整
// 启用HDMI传输
try
{
var enableResult = await hdmiIn.EnableTrans(true);
if (!enableResult.IsSuccessful)
{
logger.Error($"Failed to enable HDMI transmission for board {boardId}: {enableResult.Error}");
return null;
}
logger.Info($"Successfully enabled HDMI transmission for board {boardId}");
}
catch (Exception ex)
{
logger.Error(ex, $"Exception occurred while enabling HDMI transmission for board {boardId}");
return null;
}
_hdmiInDict[boardId] = hdmiIn;
_hdmiInCtsDict[boardId] = new CancellationTokenSource();
return hdmiIn;
}
private async Task HandleRequestAsync(HttpListenerContext context, CancellationToken cancellationToken)
{
var path = context.Request.Url?.AbsolutePath ?? "/";
var boardId = context.Request.QueryString["boardId"];
if (string.IsNullOrEmpty(boardId))
{
await SendErrorAsync(context.Response, "Missing boardId");
return;
}
var hdmiIn = await GetOrCreateHdmiInAsync(boardId);
if (hdmiIn == null)
{
await SendErrorAsync(context.Response, "Invalid boardId or board not available");
return;
}
var hdmiInToken = _hdmiInCtsDict[boardId].Token;
if (hdmiInToken == null)
{
await SendErrorAsync(context.Response, "HDMI input is not available");
return;
}
if (path == "/snapshot")
{
await HandleSnapshotRequestAsync(context.Response, hdmiIn, hdmiInToken);
}
else if (path == "/mjpeg")
{
await HandleMjpegStreamAsync(context.Response, hdmiIn, hdmiInToken);
}
else if (path == "/video")
{
await SendVideoHtmlPageAsync(context.Response, boardId);
}
else
{
await SendIndexHtmlPageAsync(context.Response, boardId);
}
}
private async Task HandleSnapshotRequestAsync(HttpListenerResponse response, HdmiIn hdmiIn, CancellationToken cancellationToken)
{
try
{
logger.Debug("处理HDMI快照请求");
const int frameWidth = 960; // HDMI输入分辨率
const int frameHeight = 540;
// 从HDMI读取RGB565数据
var frameResult = await hdmiIn.ReadFrame();
if (!frameResult.IsSuccessful || frameResult.Value == null)
{
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 rgb565Data = frameResult.Value;
// 验证数据长度
var expectedLength = frameWidth * frameHeight * 2;
if (rgb565Data.Length != expectedLength)
{
logger.Warn("HDMI快照数据长度不匹配期望: {Expected}, 实际: {Actual}",
expectedLength, rgb565Data.Length);
}
// 将RGB565转换为RGB24
var rgb24Result = Common.Image.ConvertRGB565ToRGB24(rgb565Data, frameWidth, frameHeight, isLittleEndian: false);
if (!rgb24Result.IsSuccessful)
{
logger.Error("HDMI快照RGB565转RGB24失败: {Error}", rgb24Result.Error);
response.StatusCode = 500;
var errorBytes = System.Text.Encoding.UTF8.GetBytes("Failed to process HDMI snapshot");
await response.OutputStream.WriteAsync(errorBytes, 0, errorBytes.Length, cancellationToken);
response.Close();
return;
}
// 将RGB24转换为JPEG
var jpegResult = Common.Image.ConvertRGB24ToJpeg(rgb24Result.Value, frameWidth, frameHeight, 80);
if (!jpegResult.IsSuccessful)
{
logger.Error("HDMI快照RGB24转JPEG失败: {Error}", jpegResult.Error);
response.StatusCode = 500;
var errorBytes = System.Text.Encoding.UTF8.GetBytes("Failed to encode HDMI snapshot");
await response.OutputStream.WriteAsync(errorBytes, 0, errorBytes.Length, cancellationToken);
response.Close();
return;
}
var jpegData = jpegResult.Value;
// 设置响应头参考Camera版本
response.ContentType = "image/jpeg";
response.ContentLength64 = jpegData.Length;
response.Headers.Add("Cache-Control", "no-cache, no-store, must-revalidate");
await response.OutputStream.WriteAsync(jpegData, 0, jpegData.Length, cancellationToken);
await response.OutputStream.FlushAsync(cancellationToken);
logger.Debug("已发送HDMI快照图像大小{Size} 字节", jpegData.Length);
}
catch (Exception ex)
{
logger.Error(ex, "处理HDMI快照请求时出错");
response.StatusCode = 500;
}
finally
{
response.Close();
}
}
private async Task HandleMjpegStreamAsync(HttpListenerResponse response, HdmiIn hdmiIn, CancellationToken cancellationToken)
{
try
{
// 设置MJPEG流的响应头参考Camera版本
response.ContentType = "multipart/x-mixed-replace; boundary=--boundary";
response.Headers.Add("Cache-Control", "no-cache, no-store, must-revalidate");
response.Headers.Add("Pragma", "no-cache");
response.Headers.Add("Expires", "0");
logger.Debug("开始HDMI MJPEG流传输");
int frameCounter = 0;
const int frameWidth = 960; // HDMI输入分辨率
const int frameHeight = 540;
while (!cancellationToken.IsCancellationRequested)
{
try
{
var frameStartTime = DateTime.UtcNow;
// 从HDMI读取RGB565数据
var readStartTime = DateTime.UtcNow;
var frameResult = await hdmiIn.ReadFrame();
var readEndTime = DateTime.UtcNow;
var readTime = (readEndTime - readStartTime).TotalMilliseconds;
if (!frameResult.IsSuccessful || frameResult.Value == null)
{
logger.Warn("HDMI帧读取失败或为空");
continue;
}
var rgb565Data = frameResult.Value;
// 验证数据长度是否正确 (RGB565为每像素2字节)
var expectedLength = frameWidth * frameHeight * 2;
if (rgb565Data.Length != expectedLength)
{
logger.Warn("HDMI数据长度不匹配期望: {Expected}, 实际: {Actual}",
expectedLength, rgb565Data.Length);
}
// 将RGB565转换为RGB24参考Camera版本的处理
var convertStartTime = DateTime.UtcNow;
var rgb24Result = Common.Image.ConvertRGB565ToRGB24(rgb565Data, frameWidth, frameHeight, isLittleEndian: false);
var convertEndTime = DateTime.UtcNow;
var convertTime = (convertEndTime - convertStartTime).TotalMilliseconds;
if (!rgb24Result.IsSuccessful)
{
logger.Error("HDMI RGB565转RGB24失败: {Error}", rgb24Result.Error);
continue;
}
// 将RGB24转换为JPEG参考Camera版本的处理
var jpegStartTime = DateTime.UtcNow;
var jpegResult = Common.Image.ConvertRGB24ToJpeg(rgb24Result.Value, frameWidth, frameHeight, 80);
var jpegEndTime = DateTime.UtcNow;
var jpegTime = (jpegEndTime - jpegStartTime).TotalMilliseconds;
if (!jpegResult.IsSuccessful)
{
logger.Error("HDMI RGB24转JPEG失败: {Error}", jpegResult.Error);
continue;
}
var jpegData = jpegResult.Value;
// 发送MJPEG帧使用Camera版本的格式
var mjpegFrameHeader = Common.Image.CreateMjpegFrameHeader(jpegData.Length);
var mjpegFrameFooter = Common.Image.CreateMjpegFrameFooter();
await response.OutputStream.WriteAsync(mjpegFrameHeader, 0, mjpegFrameHeader.Length, cancellationToken);
await response.OutputStream.WriteAsync(jpegData, 0, jpegData.Length, cancellationToken);
await response.OutputStream.WriteAsync(mjpegFrameFooter, 0, mjpegFrameFooter.Length, cancellationToken);
await response.OutputStream.FlushAsync(cancellationToken);
frameCounter++;
var totalTime = (DateTime.UtcNow - frameStartTime).TotalMilliseconds;
// 性能统计日志每30帧记录一次
if (frameCounter % 30 == 0)
{
logger.Debug("HDMI帧 {FrameNumber} 性能统计 - 读取: {ReadTime:F1}ms, RGB转换: {ConvertTime:F1}ms, JPEG转换: {JpegTime:F1}ms, 总计: {TotalTime:F1}ms, JPEG大小: {JpegSize} 字节",
frameCounter, readTime, convertTime, jpegTime, totalTime, jpegData.Length);
}
}
catch (Exception ex)
{
logger.Error(ex, "处理HDMI帧时发生错误");
}
}
}
catch (Exception ex)
{
logger.Error(ex, "HDMI MJPEG流处理异常");
}
finally
{
try
{
// 停止传输时禁用HDMI传输
await hdmiIn.EnableTrans(false);
logger.Info("已禁用HDMI传输");
}
catch (Exception ex)
{
logger.Error(ex, "禁用HDMI传输时出错");
}
try
{
response.Close();
}
catch
{
// 忽略关闭时的错误
}
logger.Debug("HDMI MJPEG流连接已关闭");
}
}
private async Task SendVideoHtmlPageAsync(HttpListenerResponse response, string boardId)
{
string html = $@"<html><body>
<h1>HDMI Video Stream for Board {boardId}</h1>
<img src='/mjpeg?boardId={boardId}' />
</body></html>";
response.ContentType = "text/html";
await response.OutputStream.WriteAsync(System.Text.Encoding.UTF8.GetBytes(html));
response.Close();
}
private async Task SendIndexHtmlPageAsync(HttpListenerResponse response, string boardId)
{
string html = $@"<html><body>
<h1>Welcome to HDMI Video Stream Service</h1>
<a href='/video?boardId={boardId}'>View Video Stream for Board {boardId}</a>
</body></html>";
response.ContentType = "text/html";
await response.OutputStream.WriteAsync(System.Text.Encoding.UTF8.GetBytes(html));
response.Close();
}
private async Task SendErrorAsync(HttpListenerResponse response, string message)
{
response.StatusCode = 400;
await response.OutputStream.WriteAsync(System.Text.Encoding.UTF8.GetBytes(message));
response.Close();
}
/// <summary>
/// 获取所有可用的HDMI视频流终端点
/// </summary>
/// <returns>返回所有可用的HDMI视频流终端点列表</returns>
public List<HdmiVideoStreamEndpoint>? GetAllVideoEndpoints()
{
var db = new Database.AppDataConnection();
var boards = db?.GetAllBoard();
if (boards == null)
return null;
var endpoints = new List<HdmiVideoStreamEndpoint>();
foreach (var board in boards)
{
endpoints.Add(new HdmiVideoStreamEndpoint
{
BoardId = board.ID.ToString(),
MjpegUrl = $"http://{Global.localhost}:{_serverPort}/mjpeg?boardId={board.ID}",
VideoUrl = $"http://{Global.localhost}:{_serverPort}/video?boardId={board.ID}",
SnapshotUrl = $"http://{Global.localhost}:{_serverPort}/snapshot?boardId={board.ID}"
});
}
return endpoints;
}
/// <summary>
/// 获取指定板卡ID的HDMI视频流终端点
/// </summary>
/// <param name="boardId">板卡ID</param>
/// <returns>返回指定板卡的HDMI视频流终端点</returns>
public HdmiVideoStreamEndpoint GetVideoEndpoint(string boardId)
{
return new HdmiVideoStreamEndpoint
{
BoardId = boardId,
MjpegUrl = $"http://{Global.localhost}:{_serverPort}/mjpeg?boardId={boardId}",
VideoUrl = $"http://{Global.localhost}:{_serverPort}/video?boardId={boardId}",
SnapshotUrl = $"http://{Global.localhost}:{_serverPort}/snapshot?boardId={boardId}"
};
}
}

View File

@@ -1109,6 +1109,7 @@ public class HttpVideoStreamService : BackgroundService
return new List<(int, int, string)> return new List<(int, int, string)>
{ {
(640, 480, "640x480 (VGA)"), (640, 480, "640x480 (VGA)"),
(960, 540, "960x540 (qHD)"),
(1280, 720, "1280x720 (HD)"), (1280, 720, "1280x720 (HD)"),
(1280, 960, "1280x960 (SXGA)"), (1280, 960, "1280x960 (SXGA)"),
(1920, 1080, "1920x1080 (Full HD)") (1920, 1080, "1920x1080 (Full HD)")

View File

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

View File

@@ -3,6 +3,7 @@ using System.Net.Sockets;
using System.Text; using System.Text;
using DotNext; using DotNext;
using WebProtocol; using WebProtocol;
using server.Services;
/// <summary> /// <summary>
/// UDP客户端发送池 /// UDP客户端发送池
@@ -433,11 +434,12 @@ public class UDPClientPool
/// <param name="endPoint">IP端点IP地址与端口</param> /// <param name="endPoint">IP端点IP地址与端口</param>
/// <param name="taskID">任务ID</param> /// <param name="taskID">任务ID</param>
/// <param name="devAddr">设备地址</param> /// <param name="devAddr">设备地址</param>
/// <param name="burstType">突发类型</param>
/// <param name="dataLength">要读取的数据长度4字节</param> /// <param name="dataLength">要读取的数据长度4字节</param>
/// <param name="timeout">超时时间(毫秒)</param> /// <param name="timeout">超时时间(毫秒)</param>
/// <returns>读取结果,包含接收到的字节数组</returns> /// <returns>读取结果,包含接收到的字节数组</returns>
public static async ValueTask<Result<byte[]>> ReadAddr4BytesAsync( public static async ValueTask<Result<byte[]>> ReadAddr4BytesAsync(
IPEndPoint endPoint, int taskID, UInt32 devAddr, int dataLength, int timeout = 1000) IPEndPoint endPoint, int taskID, UInt32 devAddr, int dataLength, BurstType burstType, int timeout = 1000)
{ {
var pkgList = new List<SendAddrPackage>(); var pkgList = new List<SendAddrPackage>();
var resultData = new List<byte>(); var resultData = new List<byte>();
@@ -460,11 +462,12 @@ public class UDPClientPool
var opts = new SendAddrPackOptions var opts = new SendAddrPackOptions
{ {
BurstType = BurstType.FixedBurst, BurstType = burstType,
CommandID = Convert.ToByte(taskID), CommandID = Convert.ToByte(taskID),
IsWrite = false, IsWrite = false,
BurstLength = (byte)(currentSegmentSize - 1), BurstLength = (byte)(currentSegmentSize - 1),
Address = devAddr + (uint)(i * max4BytesPerRead) Address = (burstType == BurstType.ExtendBurst) ? (devAddr + (uint)(i * max4BytesPerRead)) : (devAddr),
// Address = devAddr + (uint)(i * max4BytesPerRead),
}; };
pkgList.Add(new SendAddrPackage(opts)); pkgList.Add(new SendAddrPackage(opts));
} }
@@ -584,7 +587,8 @@ public class UDPClientPool
/// <param name="timeout">超时时间(毫秒)</param> /// <param name="timeout">超时时间(毫秒)</param>
/// <returns>写入结果true表示写入成功</returns> /// <returns>写入结果true表示写入成功</returns>
public static async ValueTask<Result<bool>> WriteAddr( public static async ValueTask<Result<bool>> WriteAddr(
IPEndPoint endPoint, int taskID, UInt32 devAddr, UInt32 data, int timeout = 1000) IPEndPoint endPoint, int taskID, UInt32 devAddr,
UInt32 data, int timeout = 1000, ProgressReporter? progress = null)
{ {
var ret = false; var ret = false;
var opts = new SendAddrPackOptions() var opts = new SendAddrPackOptions()
@@ -595,14 +599,17 @@ public class UDPClientPool
Address = devAddr, Address = devAddr,
IsWrite = true, IsWrite = true,
}; };
progress?.Report(20);
// Write Register // Write Register
ret = await UDPClientPool.SendAddrPackAsync(endPoint, new SendAddrPackage(opts)); ret = await UDPClientPool.SendAddrPackAsync(endPoint, new SendAddrPackage(opts));
if (!ret) return new(new Exception("Send 1st address package failed!")); if (!ret) return new(new Exception("Send 1st address package failed!"));
progress?.Report(40);
// Send Data Package // Send Data Package
ret = await UDPClientPool.SendDataPackAsync(endPoint, ret = await UDPClientPool.SendDataPackAsync(endPoint,
new SendDataPackage(Common.Number.NumberToBytes(data, 4).Value)); new SendDataPackage(Common.Number.NumberToBytes(data, 4).Value));
if (!ret) return new(new Exception("Send data package failed!")); if (!ret) return new(new Exception("Send data package failed!"));
progress?.Report(60);
// Check Msg Bus // Check Msg Bus
if (!MsgBus.IsRunning) if (!MsgBus.IsRunning)
@@ -612,6 +619,7 @@ public class UDPClientPool
var udpWriteAck = await MsgBus.UDPServer.WaitForAckAsync( var udpWriteAck = await MsgBus.UDPServer.WaitForAckAsync(
endPoint.Address.ToString(), taskID, endPoint.Port, timeout); endPoint.Address.ToString(), taskID, endPoint.Port, timeout);
if (!udpWriteAck.IsSuccessful) return new(udpWriteAck.Error); if (!udpWriteAck.IsSuccessful) return new(udpWriteAck.Error);
progress?.Finish();
return udpWriteAck.Value.IsSuccessful; return udpWriteAck.Value.IsSuccessful;
} }
@@ -626,7 +634,8 @@ public class UDPClientPool
/// <param name="timeout">超时时间(毫秒)</param> /// <param name="timeout">超时时间(毫秒)</param>
/// <returns>写入结果true表示写入成功</returns> /// <returns>写入结果true表示写入成功</returns>
public static async ValueTask<Result<bool>> WriteAddr( public static async ValueTask<Result<bool>> WriteAddr(
IPEndPoint endPoint, int taskID, UInt32 devAddr, byte[] dataArray, int timeout = 1000) IPEndPoint endPoint, int taskID, UInt32 devAddr,
byte[] dataArray, int timeout = 1000, ProgressReporter? progress = null)
{ {
var ret = false; var ret = false;
var opts = new SendAddrPackOptions() var opts = new SendAddrPackOptions()
@@ -648,6 +657,8 @@ public class UDPClientPool
var writeTimes = hasRest ? var writeTimes = hasRest ?
dataArray.Length / (max4BytesPerRead * (32 / 8)) + 1 : dataArray.Length / (max4BytesPerRead * (32 / 8)) + 1 :
dataArray.Length / (max4BytesPerRead * (32 / 8)); dataArray.Length / (max4BytesPerRead * (32 / 8));
if (progress != null)
progress.ExpectedSteps = writeTimes;
for (var i = 0; i < writeTimes; i++) for (var i = 0; i < writeTimes; i++)
{ {
// Sperate Data Array // Sperate Data Array
@@ -676,8 +687,11 @@ public class UDPClientPool
if (!udpWriteAck.Value.IsSuccessful) if (!udpWriteAck.Value.IsSuccessful)
return false; return false;
progress?.Increase();
} }
progress?.Finish();
return true; return true;
} }

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,8 @@
/* tslint:disable */ /* tslint:disable */
// @ts-nocheck // @ts-nocheck
import type { HubConnection, IStreamResult, Subject } from '@microsoft/signalr'; import type { HubConnection, IStreamResult, Subject } from '@microsoft/signalr';
import type { IJtagHub, IJtagReceiver } from './server.Hubs.JtagHub'; import type { IJtagHub, IProgressHub, IJtagReceiver, IProgressReceiver } from './server.Hubs';
import type { ProgressInfo } from '../server.Hubs';
// components // components
@@ -43,22 +44,30 @@ class ReceiverMethodSubscription implements Disposable {
export type HubProxyFactoryProvider = { export type HubProxyFactoryProvider = {
(hubType: "IJtagHub"): HubProxyFactory<IJtagHub>; (hubType: "IJtagHub"): HubProxyFactory<IJtagHub>;
(hubType: "IProgressHub"): HubProxyFactory<IProgressHub>;
} }
export const getHubProxyFactory = ((hubType: string) => { export const getHubProxyFactory = ((hubType: string) => {
if(hubType === "IJtagHub") { if(hubType === "IJtagHub") {
return IJtagHub_HubProxyFactory.Instance; return IJtagHub_HubProxyFactory.Instance;
} }
if(hubType === "IProgressHub") {
return IProgressHub_HubProxyFactory.Instance;
}
}) as HubProxyFactoryProvider; }) as HubProxyFactoryProvider;
export type ReceiverRegisterProvider = { export type ReceiverRegisterProvider = {
(receiverType: "IJtagReceiver"): ReceiverRegister<IJtagReceiver>; (receiverType: "IJtagReceiver"): ReceiverRegister<IJtagReceiver>;
(receiverType: "IProgressReceiver"): ReceiverRegister<IProgressReceiver>;
} }
export const getReceiverRegister = ((receiverType: string) => { export const getReceiverRegister = ((receiverType: string) => {
if(receiverType === "IJtagReceiver") { if(receiverType === "IJtagReceiver") {
return IJtagReceiver_Binder.Instance; return IJtagReceiver_Binder.Instance;
} }
if(receiverType === "IProgressReceiver") {
return IProgressReceiver_Binder.Instance;
}
}) as ReceiverRegisterProvider; }) as ReceiverRegisterProvider;
// HubProxy // HubProxy
@@ -92,6 +101,27 @@ class IJtagHub_HubProxy implements IJtagHub {
} }
} }
class IProgressHub_HubProxyFactory implements HubProxyFactory<IProgressHub> {
public static Instance = new IProgressHub_HubProxyFactory();
private constructor() {
}
public readonly createHubProxy = (connection: HubConnection): IProgressHub => {
return new IProgressHub_HubProxy(connection);
}
}
class IProgressHub_HubProxy implements IProgressHub {
public constructor(private connection: HubConnection) {
}
public readonly join = async (taskId: string): Promise<boolean> => {
return await this.connection.invoke("Join", taskId);
}
}
// Receiver // Receiver
@@ -116,3 +146,24 @@ class IJtagReceiver_Binder implements ReceiverRegister<IJtagReceiver> {
} }
} }
class IProgressReceiver_Binder implements ReceiverRegister<IProgressReceiver> {
public static Instance = new IProgressReceiver_Binder();
private constructor() {
}
public readonly register = (connection: HubConnection, receiver: IProgressReceiver): Disposable => {
const __onReceiveProgress = (...args: [ProgressInfo]) => receiver.onReceiveProgress(...args);
connection.on("OnReceiveProgress", __onReceiveProgress);
const methodList: ReceiverMethod[] = [
{ methodName: "OnReceiveProgress", method: __onReceiveProgress }
]
return new ReceiverMethodSubscription(connection, methodList);
}
}

View File

@@ -3,6 +3,7 @@
/* tslint:disable */ /* tslint:disable */
// @ts-nocheck // @ts-nocheck
import type { IStreamResult, Subject } from '@microsoft/signalr'; import type { IStreamResult, Subject } from '@microsoft/signalr';
import type { ProgressInfo } from '../server.Hubs';
export type IJtagHub = { export type IJtagHub = {
/** /**
@@ -21,6 +22,14 @@ export type IJtagHub = {
stopBoundaryScan(): Promise<boolean>; stopBoundaryScan(): Promise<boolean>;
} }
export type IProgressHub = {
/**
* @param taskId Transpiled from string
* @returns Transpiled from System.Threading.Tasks.Task<bool>
*/
join(taskId: string): Promise<boolean>;
}
export type IJtagReceiver = { export type IJtagReceiver = {
/** /**
* @param msg Transpiled from System.Collections.Generic.Dictionary<string, bool> * @param msg Transpiled from System.Collections.Generic.Dictionary<string, bool>
@@ -29,3 +38,11 @@ export type IJtagReceiver = {
onReceiveBoundaryScanData(msg: Partial<Record<string, boolean>>): Promise<void>; onReceiveBoundaryScanData(msg: Partial<Record<string, boolean>>): Promise<void>;
} }
export type IProgressReceiver = {
/**
* @param message Transpiled from server.Hubs.ProgressInfo
* @returns Transpiled from System.Threading.Tasks.Task
*/
onReceiveProgress(message: ProgressInfo): Promise<void>;
}

View File

@@ -88,17 +88,17 @@ export async function loadDiagramData(examId?: string): Promise<DiagramData> {
// 如果提供了examId优先从API加载实验的diagram // 如果提供了examId优先从API加载实验的diagram
if (examId) { if (examId) {
try { try {
const examClient = AuthManager.createAuthenticatedExamClient(); const resourceClient = AuthManager.createAuthenticatedResourceClient();
// 获取diagram类型的资源列表 // 获取diagram类型的资源列表
const resources = await examClient.getExamResourceList(examId, 'canvas'); const resources = await resourceClient.getResourceList(examId, 'canvas', 'template');
if (resources && resources.length > 0) { if (resources && resources.length > 0) {
// 获取第一个diagram资源 // 获取第一个diagram资源
const diagramResource = resources[0]; const diagramResource = resources[0];
// 使用动态API获取资源文件内容 // 使用动态API获取资源文件内容
const response = await examClient.getExamResourceById(diagramResource.id); const response = await resourceClient.getResourceById(diagramResource.id);
if (response && response.data) { if (response && response.data) {
const text = await response.data.text(); const text = await response.data.text();

View File

@@ -10,6 +10,7 @@ import {
SignalTriggerConfig, SignalTriggerConfig,
SignalValue, SignalValue,
AnalyzerChannelDiv, AnalyzerChannelDiv,
AnalyzerClockDiv,
} from "@/APIClient"; } from "@/APIClient";
import { AuthManager } from "@/utils/AuthManager"; import { AuthManager } from "@/utils/AuthManager";
import { useAlertStore } from "@/components/Alert"; import { useAlertStore } from "@/components/Alert";
@@ -30,16 +31,8 @@ export type Channel = {
// 全局模式选项 // 全局模式选项
const globalModes = [ const globalModes = [
{ {value: GlobalCaptureMode.AND,label: "AND",description: "所有条件都满足时触发",},
value: GlobalCaptureMode.AND, {value: GlobalCaptureMode.OR,label: "OR",description: "任一条件满足时触发",},
label: "AND",
description: "所有条件都满足时触发",
},
{
value: GlobalCaptureMode.OR,
label: "OR",
description: "任一条件满足时触发",
},
{ value: GlobalCaptureMode.NAND, label: "NAND", description: "AND的非" }, { value: GlobalCaptureMode.NAND, label: "NAND", description: "AND的非" },
{ value: GlobalCaptureMode.NOR, label: "NOR", description: "OR的非" }, { value: GlobalCaptureMode.NOR, label: "NOR", description: "OR的非" },
]; ];
@@ -76,28 +69,23 @@ const channelDivOptions = [
{ value: 32, label: "32通道", description: "启用32个通道 (CH0-CH31)" }, { value: 32, label: "32通道", description: "启用32个通道 (CH0-CH31)" },
]; ];
// 捕获深度选项 const ClockDivOptions = [
const captureLengthOptions = [ { value: AnalyzerClockDiv.DIV1, label: "120MHz", description: "采样频率120MHz" },
{ value: 256, label: "256" }, { value: AnalyzerClockDiv.DIV2, label: "60MHz", description: "采样频率60MHz" },
{ value: 512, label: "512" }, { value: AnalyzerClockDiv.DIV4, label: "30MHz", description: "采样频率30MHz" },
{ value: 1024, label: "1K" }, { value: AnalyzerClockDiv.DIV8, label: "15MHz", description: "采样频率15MHz" },
{ value: 2048, label: "2K" }, { value: AnalyzerClockDiv.DIV16, label: "7.5MHz", description: "采样频率7.5MHz" },
{ value: 4096, label: "4K" }, { value: AnalyzerClockDiv.DIV32, label: "3.75MHz", description: "采样频率3.75MHz" },
{ value: 8192, label: "8K" }, { value: AnalyzerClockDiv.DIV64, label: "1.875MHz", description: "采样频率1.875MHz" },
{ value: 16384, label: "16K" }, { value: AnalyzerClockDiv.DIV128, label: "937.5KHz", description: "采样频率937.5KHz" },
{ value: 32768, label: "32K" },
]; ];
// 捕获深度选项 // 捕获深度限制常量
const preCaptureLengthOptions = [ const CAPTURE_LENGTH_MIN = 1024; // 最小捕获深度 1024
{ value: 0, label: "0" }, const CAPTURE_LENGTH_MAX = 0x10000000 - 0x01000000; // 最大捕获深度
{ value: 16, label: "16" },
{ value: 32, label: "32" }, // 预捕获深度限制常量
{ value: 64, label: "64" }, const PRE_CAPTURE_LENGTH_MIN = 2; // 最小预捕获深度 2
{ value: 128, label: "128" },
{ value: 256, label: "256" },
{ value: 512, label: "512" },
];
// 默认颜色数组 // 默认颜色数组
const defaultColors = [ const defaultColors = [
@@ -111,9 +99,8 @@ const defaultColors = [
"#8C33FF", "#8C33FF",
]; ];
// 添加逻辑分析仪频率常量 // 添加逻辑分析仪基础频率常量
const LOGIC_ANALYZER_FREQUENCY = 125_000_000; // 125MHz const BASE_LOGIC_ANALYZER_FREQUENCY = 120_000_000; // 120MHz基础频率
const SAMPLE_PERIOD_NS = 1_000_000_000 / LOGIC_ANALYZER_FREQUENCY; // 采样周期,单位:纳秒
const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState( const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
() => { () => {
@@ -126,8 +113,9 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
// 触发设置相关状态 // 触发设置相关状态
const currentGlobalMode = ref<GlobalCaptureMode>(GlobalCaptureMode.AND); const currentGlobalMode = ref<GlobalCaptureMode>(GlobalCaptureMode.AND);
const currentChannelDiv = ref<number>(8); // 默认启用8个通道 const currentChannelDiv = ref<number>(8); // 默认启用8个通道
const captureLength = ref<number>(1024); // 捕获深度,默认1024 const captureLength = ref<number>(CAPTURE_LENGTH_MIN); // 捕获深度,默认为最小值
const preCaptureLength = ref<number>(0); // 预捕获深度默认0 const preCaptureLength = ref<number>(PRE_CAPTURE_LENGTH_MIN); // 预捕获深度默认0
const currentclockDiv = ref<AnalyzerClockDiv>(AnalyzerClockDiv.DIV1); // 默认时钟分频为1
const isApplying = ref(false); const isApplying = ref(false);
const isCapturing = ref(false); // 添加捕获状态标识 const isCapturing = ref(false); // 添加捕获状态标识
@@ -168,6 +156,17 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
channels.filter((channel) => channel.enabled), channels.filter((channel) => channel.enabled),
); );
// 计算属性:根据当前时钟分频获取实际采样频率
const currentSampleFrequency = computed(() => {
const divValue = Math.pow(2, currentclockDiv.value);
return BASE_LOGIC_ANALYZER_FREQUENCY / divValue;
});
// 计算属性:获取当前采样周期(纳秒)
const currentSamplePeriodNs = computed(() => {
return 1_000_000_000 / currentSampleFrequency.value;
});
// 转换通道数字到枚举值 // 转换通道数字到枚举值
const getChannelDivEnum = (channelCount: number): AnalyzerChannelDiv => { const getChannelDivEnum = (channelCount: number): AnalyzerChannelDiv => {
switch (channelCount) { switch (channelCount) {
@@ -181,6 +180,64 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
} }
}; };
// 验证捕获深度
const validateCaptureLength = (value: number): { valid: boolean; message?: string } => {
if (!Number.isInteger(value)) {
return { valid: false, message: "捕获深度必须是整数" };
}
if (value < CAPTURE_LENGTH_MIN) {
return { valid: false, message: `捕获深度不能小于 ${CAPTURE_LENGTH_MIN}` };
}
if (value > CAPTURE_LENGTH_MAX) {
return { valid: false, message: `捕获深度不能大于 ${CAPTURE_LENGTH_MAX.toLocaleString()}` };
}
return { valid: true };
};
// 验证预捕获深度
const validatePreCaptureLength = (value: number, currentCaptureLength: number): { valid: boolean; message?: string } => {
if (!Number.isInteger(value)) {
return { valid: false, message: "预捕获深度必须是整数" };
}
if (value < PRE_CAPTURE_LENGTH_MIN) {
return { valid: false, message: `预捕获深度不能小于 ${PRE_CAPTURE_LENGTH_MIN}` };
}
if (value >= currentCaptureLength) {
return { valid: false, message: `预捕获深度不能大于等于捕获深度 (${currentCaptureLength})` };
}
return { valid: true };
};
// 设置捕获深度
const setCaptureLength = (value: number) => {
const validation = validateCaptureLength(value);
if (!validation.valid) {
alert?.error(validation.message!, 3000);
return false;
}
// 检查预捕获深度是否仍然有效
if (preCaptureLength.value >= value) {
preCaptureLength.value = Math.max(0, value - 1);
alert?.warn(`预捕获深度已自动调整为 ${preCaptureLength.value}`, 3000);
}
captureLength.value = value;
return true;
};
// 设置预捕获深度
const setPreCaptureLength = (value: number) => {
const validation = validatePreCaptureLength(value, captureLength.value);
if (!validation.valid) {
alert?.error(validation.message!, 3000);
return false;
}
preCaptureLength.value = value;
return true;
};
// 设置通道组 // 设置通道组
const setChannelDiv = (channelCount: number) => { const setChannelDiv = (channelCount: number) => {
// 验证通道数量是否有效 // 验证通道数量是否有效
@@ -210,9 +267,16 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
alert?.info(`全局触发模式已设置为 ${modeOption?.label}`, 2000); alert?.info(`全局触发模式已设置为 ${modeOption?.label}`, 2000);
}; };
const setClockDiv = (mode: AnalyzerClockDiv) => {
currentclockDiv.value = mode;
const modeOption = ClockDivOptions.find((m) => m.value === mode);
alert?.info(`时钟分频已设置为 ${modeOption?.label}`, 2000);
};
const resetConfiguration = () => { const resetConfiguration = () => {
currentGlobalMode.value = GlobalCaptureMode.AND; currentGlobalMode.value = GlobalCaptureMode.AND;
currentChannelDiv.value = 8; // 重置为默认的8通道 currentChannelDiv.value = 8; // 重置为默认的8通道
currentclockDiv.value = AnalyzerClockDiv.DIV1; // 重置为默认采样频率
setChannelDiv(8); // 重置为默认的8通道 setChannelDiv(8); // 重置为默认的8通道
signalConfigs.forEach((signal) => { signalConfigs.forEach((signal) => {
@@ -243,7 +307,7 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
// 根据当前通道数量解析数据 // 根据当前通道数量解析数据
const channelCount = currentChannelDiv.value; const channelCount = currentChannelDiv.value;
const timeStepNs = SAMPLE_PERIOD_NS; const timeStepNs = currentSamplePeriodNs.value;
let sampleCount: number; let sampleCount: number;
let x: number[]; let x: number[];
@@ -486,6 +550,7 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
channelDiv: getChannelDivEnum(currentChannelDiv.value), channelDiv: getChannelDivEnum(currentChannelDiv.value),
captureLength: captureLength.value, captureLength: captureLength.value,
preCaptureLength: preCaptureLength.value, preCaptureLength: preCaptureLength.value,
clockDiv: currentclockDiv.value,
signalConfigs: allSignals, signalConfigs: allSignals,
}); });
@@ -624,13 +689,13 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
// 添加生成测试数据的方法 // 添加生成测试数据的方法
const generateTestData = () => { const generateTestData = () => {
const sampleRate = LOGIC_ANALYZER_FREQUENCY; // 使用实际的逻辑分析仪频率 const sampleRate = currentSampleFrequency.value; // 使用当前设置的采样频率
const duration = 0.001; // 1ms的数据 const duration = 0.001; // 1ms的数据
const points = Math.floor(sampleRate * duration); const points = Math.floor(sampleRate * duration);
const x = Array.from( const x = Array.from(
{ length: points }, { length: points },
(_, i) => (i * SAMPLE_PERIOD_NS) / 1000, // 时间轴,单位:微秒 (_, i) => (i * currentSamplePeriodNs.value) / 1000, // 时间轴,单位:微秒
); );
// Generate 8 channels with different digital patterns // Generate 8 channels with different digital patterns
@@ -703,6 +768,7 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
currentChannelDiv, // 导出当前通道组状态 currentChannelDiv, // 导出当前通道组状态
captureLength, // 导出捕获深度 captureLength, // 导出捕获深度
preCaptureLength, // 导出预捕获深度 preCaptureLength, // 导出预捕获深度
currentclockDiv, // 导出当前采样频率状态
isApplying, isApplying,
isCapturing, // 导出捕获状态 isCapturing, // 导出捕获状态
isOperationInProgress, // 导出操作进行状态 isOperationInProgress, // 导出操作进行状态
@@ -711,18 +777,29 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
enabledChannelCount, enabledChannelCount,
channelNames, channelNames,
enabledChannels, enabledChannels,
currentSampleFrequency, // 导出当前采样频率
currentSamplePeriodNs, // 导出当前采样周期
// 选项数据 // 选项数据
globalModes, globalModes,
operators, operators,
signalValues, signalValues,
channelDivOptions, // 导出通道组选项 channelDivOptions, // 导出通道组选项
captureLengthOptions, // 导出捕获深度选项 ClockDivOptions, // 导出采样频率选项
preCaptureLengthOptions, // 导出预捕获深度选项
// 捕获深度常量和验证
CAPTURE_LENGTH_MIN,
CAPTURE_LENGTH_MAX,
PRE_CAPTURE_LENGTH_MIN,
validateCaptureLength,
validatePreCaptureLength,
setCaptureLength,
setPreCaptureLength,
// 触发设置方法 // 触发设置方法
setChannelDiv, // 导出设置通道组方法 setChannelDiv, // 导出设置通道组方法
setGlobalMode, setGlobalMode,
setClockDiv, // 导出设置采样频率方法
resetConfiguration, resetConfiguration,
setLogicData, setLogicData,
startCapture, startCapture,

View File

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

View File

@@ -6,8 +6,6 @@ import hljs from 'highlight.js';
import 'highlight.js/styles/github.css'; // 亮色主题 import 'highlight.js/styles/github.css'; // 亮色主题
// 导入主题存储 // 导入主题存储
import { useThemeStore } from '@/stores/theme'; import { useThemeStore } from '@/stores/theme';
// 导入ExamClient用于获取图片资源
import { ExamClient } from '@/APIClient';
import { AuthManager } from '@/utils/AuthManager'; import { AuthManager } from '@/utils/AuthManager';
const props = defineProps({ const props = defineProps({
@@ -36,8 +34,8 @@ const imageResourceCache = ref<Map<string, string>>(new Map());
// 获取图片资源ID的函数 // 获取图片资源ID的函数
async function getImageResourceId(examId: string, imagePath: string): Promise<string | null> { async function getImageResourceId(examId: string, imagePath: string): Promise<string | null> {
try { try {
const client = AuthManager.createAuthenticatedExamClient(); const client = AuthManager.createAuthenticatedResourceClient();
const resources = await client.getExamResourceList(examId, 'images'); const resources = await client.getResourceList(examId, 'images', 'template');
// 查找匹配的图片资源 // 查找匹配的图片资源
const imageResource = resources.find(r => r.name === imagePath || r.name.endsWith(imagePath)); const imageResource = resources.find(r => r.name === imagePath || r.name.endsWith(imagePath));
@@ -52,8 +50,8 @@ async function getImageResourceId(examId: string, imagePath: string): Promise<st
// 通过资源ID获取图片数据URL // 通过资源ID获取图片数据URL
async function getImageDataUrl(resourceId: string): Promise<string | null> { async function getImageDataUrl(resourceId: string): Promise<string | null> {
try { try {
const client = AuthManager.createAuthenticatedExamClient(); const client = AuthManager.createAuthenticatedResourceClient();
const response = await client.getExamResourceById(parseInt(resourceId)); const response = await client.getResourceById(parseInt(resourceId));
if (response && response.data) { if (response && response.data) {
return URL.createObjectURL(response.data); return URL.createObjectURL(response.data);

View File

@@ -127,12 +127,13 @@ onMounted(async () => {
let thumbnail: string | undefined; let thumbnail: string | undefined;
try { try {
// 获取实验的封面资源 // 获取实验的封面资源(模板资源)
const resourceList = await client.getExamResourceList(exam.id, 'cover'); const resourceClient = AuthManager.createAuthenticatedResourceClient();
const resourceList = await resourceClient.getResourceList(exam.id, 'cover', 'template');
if (resourceList && resourceList.length > 0) { if (resourceList && resourceList.length > 0) {
// 使用第一个封面资源 // 使用第一个封面资源
const coverResource = resourceList[0]; const coverResource = resourceList[0];
const fileResponse = await client.getExamResourceById(coverResource.id); const fileResponse = await resourceClient.getResourceById(coverResource.id);
// 创建Blob URL作为缩略图 // 创建Blob URL作为缩略图
thumbnail = URL.createObjectURL(fileResponse.data); thumbnail = URL.createObjectURL(fileResponse.data);
} }

View File

@@ -8,7 +8,11 @@
<fieldset class="fieldset w-full"> <fieldset class="fieldset w-full">
<legend class="fieldset-legend text-sm">示例比特流文件</legend> <legend class="fieldset-legend text-sm">示例比特流文件</legend>
<div class="space-y-2"> <div class="space-y-2">
<div v-for="bitstream in availableBitstreams" :key="bitstream.id" class="flex items-center justify-between p-2 bg-base-200 rounded"> <div
v-for="bitstream in availableBitstreams"
:key="bitstream.id"
class="flex items-center justify-between p-2 border-2 border-base-300 rounded-lg"
>
<span class="text-sm">{{ bitstream.name }}</span> <span class="text-sm">{{ bitstream.name }}</span>
<div class="flex gap-2"> <div class="flex gap-2">
<button <button
@@ -18,24 +22,20 @@
> >
<div v-if="isDownloading"> <div v-if="isDownloading">
<span class="loading loading-spinner loading-xs"></span> <span class="loading loading-spinner loading-xs"></span>
下载中... {{ downloadProgress }}%
</div>
<div v-else>
下载示例
</div> </div>
<div v-else>下载示例</div>
</button> </button>
<button <button
@click="programExampleBitstream(bitstream)" @click="programExampleBitstream(bitstream)"
class="btn btn-sm btn-primary" class="btn btn-sm btn-primary"
:disabled="isDownloading || isProgramming || !uploadEvent" :disabled="isDownloading || isProgramming"
> >
<div v-if="isProgramming"> <div v-if="isProgramming">
<span class="loading loading-spinner loading-xs"></span> <span class="loading loading-spinner loading-xs"></span>
烧录中... 烧录中...
</div> </div>
<div v-else> <div v-else>直接烧录</div>
直接烧录
</div>
</button> </button>
</div> </div>
</div> </div>
@@ -44,25 +44,34 @@
</div> </div>
<!-- 分割线 --> <!-- 分割线 -->
<div v-if="examId && availableBitstreams.length > 0" class="divider"></div> <div v-if="examId && availableBitstreams.length > 0" class="divider">
</div>
<!-- Input File --> <!-- Input File -->
<fieldset class="fieldset w-full"> <fieldset class="fieldset w-full">
<legend class="fieldset-legend text-sm">上传自定义比特流文件</legend> <legend class="fieldset-legend text-sm">上传自定义比特流文件</legend>
<input type="file" ref="fileInput" class="file-input w-full" @change="handleFileChange" /> <input
type="file"
ref="fileInput"
class="file-input w-full"
@change="handleFileChange"
/>
<label class="fieldset-label">文件最大容量: {{ maxMemory }}MB</label> <label class="fieldset-label">文件最大容量: {{ maxMemory }}MB</label>
</fieldset> </fieldset>
<!-- Upload Button --> <!-- Upload Button -->
<div class="card-actions w-full"> <div class="card-actions w-full">
<button @click="handleClick" class="btn btn-primary grow" :disabled="isUploading || isProgramming"> <button
@click="handleClick"
class="btn btn-primary grow"
:disabled="isUploading || isProgramming"
>
<div v-if="isUploading"> <div v-if="isUploading">
<span class="loading loading-spinner"></span> <span class="loading loading-spinner"></span>
上传中... 上传中...
</div> </div>
<div v-else> <div v-else>上传并下载</div>
{{ buttonText }}
</div>
</button> </button>
</div> </div>
</div> </div>
@@ -73,32 +82,68 @@ import { computed, ref, useTemplateRef, onMounted } from "vue";
import { AuthManager } from "@/utils/AuthManager"; // Adjust the path based on your project structure import { AuthManager } from "@/utils/AuthManager"; // Adjust the path based on your project structure
import { useDialogStore } from "@/stores/dialog"; import { useDialogStore } from "@/stores/dialog";
import { isNull, isUndefined } from "lodash"; import { isNull, isUndefined } from "lodash";
import { useEquipments } from "@/stores/equipments";
import type { HubConnection } from "@microsoft/signalr";
import type {
IProgressHub,
IProgressReceiver,
} from "@/TypedSignalR.Client/server.Hubs";
import { getHubProxyFactory, getReceiverRegister } from "@/TypedSignalR.Client";
import { ProgressStatus } from "@/server.Hubs";
import { useRequiredInjection } from "@/utils/Common";
import { useAlertStore } from "./Alert";
interface Props { interface Props {
uploadEvent?: (file: File) => Promise<boolean>;
downloadEvent?: () => Promise<boolean>;
maxMemory?: number; maxMemory?: number;
examId?: string; // 新增examId属性 examId?: string; // 新增examId属性
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
maxMemory: 4, maxMemory: 4,
examId: '', examId: "",
}); });
const emits = defineEmits<{ const emits = defineEmits<{
finishedUpload: [file: File]; finishedUpload: [file: File];
}>(); }>();
const alert = useRequiredInjection(useAlertStore);
const dialog = useDialogStore(); const dialog = useDialogStore();
const eqps = useEquipments();
const isUploading = ref(false); const isUploading = ref(false);
const isDownloading = ref(false); const isDownloading = ref(false);
const isProgramming = ref(false); const isProgramming = ref(false);
const availableBitstreams = ref<{id: number, name: string}[]>([]); const availableBitstreams = ref<{ id: number; name: string }[]>([]);
const buttonText = computed(() => { // Progress
return isUndefined(props.downloadEvent) ? "上传" : "上传并下载"; const downloadTaskId = ref("");
const downloadProgress = ref(0);
const progressHubConnection = ref<HubConnection>();
const progressHubProxy = ref<IProgressHub>();
const progressHubReceiver: IProgressReceiver = {
onReceiveProgress: async (msg) => {
if (msg.taskId == downloadTaskId.value) {
if (msg.status == ProgressStatus.InProgress) {
downloadProgress.value = msg.progressPercent;
} else if (msg.status == ProgressStatus.Failed) {
dialog.error(msg.errorMessage);
} else if (msg.status == ProgressStatus.Completed) {
alert.info("比特流下载成功");
}
}
},
};
onMounted(async () => {
progressHubConnection.value =
AuthManager.createAuthenticatedProgressHubConnection();
progressHubProxy.value = getHubProxyFactory("IProgressHub").createHubProxy(
progressHubConnection.value,
);
getReceiverRegister("IProgressReceiver").register(
progressHubConnection.value,
progressHubReceiver,
);
}); });
const fileInput = useTemplateRef("fileInput"); const fileInput = useTemplateRef("fileInput");
@@ -120,38 +165,46 @@ onMounted(async () => {
// 加载可用的比特流文件列表 // 加载可用的比特流文件列表
async function loadAvailableBitstreams() { async function loadAvailableBitstreams() {
console.log('加载可用比特流文件examId:', props.examId); console.log("加载可用比特流文件examId:", props.examId);
if (!props.examId) { if (!props.examId) {
availableBitstreams.value = []; availableBitstreams.value = [];
return; return;
} }
try { try {
const examClient = AuthManager.createAuthenticatedExamClient(); const resourceClient = AuthManager.createAuthenticatedResourceClient();
// 使用新的API获取比特流资源列表 // 使用新的ResourceClient API获取比特流模板资源列表
const resources = await examClient.getExamResourceList(props.examId, 'bitstream'); const resources = await resourceClient.getResourceList(
availableBitstreams.value = resources.map(r => ({ id: r.id, name: r.name })) || []; props.examId,
"bitstream",
"template",
);
availableBitstreams.value =
resources.map((r) => ({ id: r.id, name: r.name })) || [];
} catch (error) { } catch (error) {
console.error('加载比特流列表失败:', error); console.error("加载比特流列表失败:", error);
availableBitstreams.value = []; availableBitstreams.value = [];
} }
} }
// 下载示例比特流 // 下载示例比特流
async function downloadExampleBitstream(bitstream: {id: number, name: string}) { async function downloadExampleBitstream(bitstream: {
id: number;
name: string;
}) {
if (isDownloading.value) return; if (isDownloading.value) return;
isDownloading.value = true; isDownloading.value = true;
try { try {
const examClient = AuthManager.createAuthenticatedExamClient(); const resourceClient = AuthManager.createAuthenticatedResourceClient();
// 使用动态API获取资源文件 // 使用新的ResourceClient API获取资源文件
const response = await examClient.getExamResourceById(bitstream.id); const response = await resourceClient.getResourceById(bitstream.id);
if (response && response.data) { if (response && response.data) {
// 创建下载链接 // 创建下载链接
const url = URL.createObjectURL(response.data); const url = URL.createObjectURL(response.data);
const link = document.createElement('a'); const link = document.createElement("a");
link.href = url; link.href = url;
link.download = response.fileName || bitstream.name; link.download = response.fileName || bitstream.name;
document.body.appendChild(link); document.body.appendChild(link);
@@ -164,7 +217,7 @@ async function downloadExampleBitstream(bitstream: {id: number, name: string}) {
dialog.error("下载失败:响应数据为空"); dialog.error("下载失败:响应数据为空");
} }
} catch (error) { } catch (error) {
console.error('下载示例比特流失败:', error); console.error("下载示例比特流失败:", error);
dialog.error("下载示例比特流失败"); dialog.error("下载示例比特流失败");
} finally { } finally {
isDownloading.value = false; isDownloading.value = false;
@@ -172,41 +225,17 @@ async function downloadExampleBitstream(bitstream: {id: number, name: string}) {
} }
// 直接烧录示例比特流 // 直接烧录示例比特流
async function programExampleBitstream(bitstream: {id: number, name: string}) { async function programExampleBitstream(bitstream: {
if (isProgramming.value || !props.uploadEvent) return; id: number;
name: string;
}) {
if (isProgramming.value) return;
isProgramming.value = true; isProgramming.value = true;
try { try {
const examClient = AuthManager.createAuthenticatedExamClient(); const downloadTaskId = await eqps.jtagDownloadBitstream(bitstream.id);
// 使用动态API获取比特流文件数据
const response = await examClient.getExamResourceById(bitstream.id);
if (!response || !response.data) {
throw new Error('获取比特流文件失败');
}
const file = new File([response.data], response.fileName || bitstream.name, { type: response.data.type });
// 调用上传事件
const uploadSuccess = await props.uploadEvent(file);
if (uploadSuccess) {
// 如果有下载事件(烧录),则执行
if (props.downloadEvent) {
const downloadSuccess = await props.downloadEvent();
if (downloadSuccess) {
dialog.info("示例比特流烧录成功");
} else {
dialog.error("烧录失败");
}
} else {
dialog.info("示例比特流上传成功");
}
} else {
dialog.error("上传失败");
}
} catch (error) { } catch (error) {
console.error('烧录示例比特流失败:', error); console.error("烧录示例比特流失败:", error);
dialog.error("烧录示例比特流失败"); dialog.error("烧录示例比特流失败");
} finally { } finally {
isProgramming.value = false; isProgramming.value = false;
@@ -234,48 +263,49 @@ function checkFile(file: File): boolean {
} }
async function handleClick(event: Event): Promise<void> { async function handleClick(event: Event): Promise<void> {
console.log("上传按钮被点击");
if (isNull(bitstream.value) || isUndefined(bitstream.value)) { if (isNull(bitstream.value) || isUndefined(bitstream.value)) {
dialog.error(`未选择文件`); dialog.error(`未选择文件`);
return; return;
} }
if (!checkFile(bitstream.value)) return; if (!checkFile(bitstream.value)) return;
if (isUndefined(props.uploadEvent)) {
dialog.error("无法上传");
return;
}
isUploading.value = true; isUploading.value = true;
let uploadedBitstreamId: number | null = null;
try { try {
const ret = await props.uploadEvent(bitstream.value); console.log("开始上传比特流文件:", bitstream.value.name);
if (isUndefined(props.downloadEvent)) { const bitstreamId = await eqps.jtagUploadBitstream(
if (ret) { bitstream.value,
dialog.info("上传成功"); props.examId || "",
emits("finishedUpload", bitstream.value); );
} else dialog.error("上传失败"); console.log("上传结果ID:", bitstreamId);
return; if (bitstreamId === null || bitstreamId === undefined) {
}
if (!ret) {
isUploading.value = false; isUploading.value = false;
return; return;
} }
uploadedBitstreamId = bitstreamId;
} catch (e) { } catch (e) {
dialog.error("上传失败"); dialog.error("上传失败");
console.error(e); console.error(e);
return; return;
} }
isUploading.value = false;
// Download // Download
try { try {
const ret = await props.downloadEvent(); console.log("开始下载比特流ID:", uploadedBitstreamId);
if (ret) dialog.info("下载成功"); if (uploadedBitstreamId === null || uploadedBitstreamId === undefined) {
else dialog.error("下载失败"); dialog.error("uploadedBitstreamId is null or undefined");
} else {
isDownloading.value = true;
downloadTaskId.value =
await eqps.jtagDownloadBitstream(uploadedBitstreamId);
}
} catch (e) { } catch (e) {
dialog.error("下载失败"); dialog.error("下载失败");
console.error(e); console.error(e);
} }
isUploading.value = false;
} }
</script> </script>

View File

@@ -22,8 +22,8 @@
</div> </div>
<div class="divider"></div> <div class="divider"></div>
<UploadCard <UploadCard
:exam-id="props.examId"
:upload-event="eqps.jtagUploadBitstream" :upload-event="eqps.jtagUploadBitstream"
:download-event="eqps.jtagDownloadBitstream"
:bitstream-file="eqps.jtagBitstream" :bitstream-file="eqps.jtagBitstream"
@update:bitstream-file="handleBitstreamChange" @update:bitstream-file="handleBitstreamChange"
> >

25
src/server.Hubs.ts Normal file
View File

@@ -0,0 +1,25 @@
/* THIS (.ts) FILE IS GENERATED BY Tapper */
/* eslint-disable */
/* tslint:disable */
/** Transpiled from server.Hubs.ProgressStatus */
export enum ProgressStatus {
Pending = 0,
InProgress = 1,
Completed = 2,
Canceled = 3,
Failed = 4,
}
/** Transpiled from server.Hubs.ProgressInfo */
export type ProgressInfo = {
/** Transpiled from string */
taskId: string;
/** Transpiled from server.Hubs.ProgressStatus */
status: ProgressStatus;
/** Transpiled from int */
progressPercent: number;
/** Transpiled from string */
errorMessage: string;
}

View File

@@ -11,7 +11,8 @@ import { toFileParameterOrUndefined } from "@/utils/Common";
import { AuthManager } from "@/utils/AuthManager"; import { AuthManager } from "@/utils/AuthManager";
import { HubConnection, HubConnectionBuilder } from "@microsoft/signalr"; import { HubConnection, HubConnectionBuilder } from "@microsoft/signalr";
import { getHubProxyFactory, getReceiverRegister } from "@/TypedSignalR.Client"; import { getHubProxyFactory, getReceiverRegister } from "@/TypedSignalR.Client";
import type { IJtagHub } from "@/TypedSignalR.Client/server.Hubs.JtagHub"; import type { ResourceInfo } from "@/APIClient";
import type { IJtagHub } from "@/TypedSignalR.Client/server.Hubs";
export const useEquipments = defineStore("equipments", () => { export const useEquipments = defineStore("equipments", () => {
// Global Stores // Global Stores
@@ -24,6 +25,7 @@ export const useEquipments = defineStore("equipments", () => {
// Jtag // Jtag
const jtagBitstream = ref<File>(); const jtagBitstream = ref<File>();
const jtagBoundaryScanFreq = ref(100); const jtagBoundaryScanFreq = ref(100);
const jtagUserBitstreams = ref<ResourceInfo[]>([]);
const jtagClientMutex = withTimeout( const jtagClientMutex = withTimeout(
new Mutex(), new Mutex(),
1000, 1000,
@@ -121,25 +123,41 @@ export const useEquipments = defineStore("equipments", () => {
enableJtagBoundaryScan.value = enable; enableJtagBoundaryScan.value = enable;
} }
async function jtagUploadBitstream(bitstream: File): Promise<boolean> { async function jtagUploadBitstream(
bitstream: File,
examId?: string,
): Promise<number | null> {
try { try {
// 自动开启电源 // 自动开启电源
await powerSetOnOff(true); await powerSetOnOff(true);
const jtagClient = AuthManager.createAuthenticatedJtagClient(); const resourceClient = AuthManager.createAuthenticatedResourceClient();
const resp = await jtagClient.uploadBitstream( const resp = await resourceClient.addResource(
boardAddr.value, "bitstream",
"user",
examId || null,
toFileParameterOrUndefined(bitstream), toFileParameterOrUndefined(bitstream),
); );
return resp;
// 如果上传成功,设置为当前选中的比特流
if (resp && resp.id !== undefined && resp.id !== null) {
return resp.id;
}
return null;
} catch (e) { } catch (e) {
dialog.error("上传错误"); dialog.error("上传错误");
console.error(e); console.error(e);
return false; return null;
} }
} }
async function jtagDownloadBitstream(): Promise<boolean> { async function jtagDownloadBitstream(bitstreamId?: number): Promise<string> {
if (bitstreamId === null || bitstreamId === undefined) {
dialog.error("请先选择要下载的比特流");
return "";
}
const release = await jtagClientMutex.acquire(); const release = await jtagClientMutex.acquire();
try { try {
// 自动开启电源 // 自动开启电源
@@ -149,12 +167,13 @@ export const useEquipments = defineStore("equipments", () => {
const resp = await jtagClient.downloadBitstream( const resp = await jtagClient.downloadBitstream(
boardAddr.value, boardAddr.value,
boardPort.value, boardPort.value,
bitstreamId,
); );
return resp; return resp;
} catch (e) { } catch (e) {
dialog.error("上传错误"); dialog.error("下载错误");
console.error(e); console.error(e);
return false; throw e;
} finally { } finally {
release(); release();
} }
@@ -281,6 +300,7 @@ export const useEquipments = defineStore("equipments", () => {
jtagBoundaryScanSetOnOff, jtagBoundaryScanSetOnOff,
jtagBitstream, jtagBitstream,
jtagBoundaryScanFreq, jtagBoundaryScanFreq,
jtagUserBitstreams,
jtagUploadBitstream, jtagUploadBitstream,
jtagDownloadBitstream, jtagDownloadBitstream,
jtagGetIDCode, jtagGetIDCode,

View File

@@ -14,6 +14,8 @@ import {
OscilloscopeApiClient, OscilloscopeApiClient,
DebuggerClient, DebuggerClient,
ExamClient, ExamClient,
ResourceClient,
HdmiVideoStreamClient,
} from "@/APIClient"; } from "@/APIClient";
import router from "@/router"; import router from "@/router";
import { HubConnectionBuilder } from "@microsoft/signalr"; import { HubConnectionBuilder } from "@microsoft/signalr";
@@ -36,7 +38,9 @@ type SupportedClient =
| NetConfigClient | NetConfigClient
| OscilloscopeApiClient | OscilloscopeApiClient
| DebuggerClient | DebuggerClient
| ExamClient; | ExamClient
| ResourceClient
| HdmiVideoStreamClient;
export class AuthManager { export class AuthManager {
// 存储token到localStorage // 存储token到localStorage
@@ -199,6 +203,14 @@ export class AuthManager {
return AuthManager.createAuthenticatedClient(ExamClient); return AuthManager.createAuthenticatedClient(ExamClient);
} }
public static createAuthenticatedResourceClient(): ResourceClient {
return AuthManager.createAuthenticatedClient(ResourceClient);
}
public static createAuthenticatedHdmiVideoStreamClient(): HdmiVideoStreamClient {
return AuthManager.createAuthenticatedClient(HdmiVideoStreamClient);
}
public static createAuthenticatedJtagHubConnection() { public static createAuthenticatedJtagHubConnection() {
const token = this.getToken(); const token = this.getToken();
if (isNull(token)) { if (isNull(token)) {
@@ -214,6 +226,21 @@ export class AuthManager {
.build(); .build();
} }
public static createAuthenticatedProgressHubConnection() {
const token = this.getToken();
if (isNull(token)) {
router.push("/login");
throw Error("Token Null!");
}
return new HubConnectionBuilder()
.withUrl("http://127.0.0.1:5000/hubs/ProgressHub", {
accessTokenFactory: () => token,
})
.withAutomaticReconnect()
.build();
}
// 登录函数 // 登录函数
public static async login( public static async login(
username: string, username: string,

View File

@@ -679,15 +679,15 @@ const downloadResources = async () => {
downloadingResources.value = true downloadingResources.value = true
try { try {
const client = AuthManager.createAuthenticatedExamClient() const resourceClient = AuthManager.createAuthenticatedResourceClient()
// 获取资源包列表 // 获取资源包列表(模板资源)
const resourceList = await client.getExamResourceList(selectedExam.value.id, 'resource') const resourceList = await resourceClient.getResourceList(selectedExam.value.id, 'resource', 'template')
if (resourceList && resourceList.length > 0) { if (resourceList && resourceList.length > 0) {
// 使用动态API获取第一个资源包 // 使用新的ResourceClient API获取第一个资源包
const resourceId = resourceList[0].id const resourceId = resourceList[0].id
const fileResponse = await client.getExamResourceById(resourceId) const fileResponse = await resourceClient.getResourceById(resourceId)
// 创建Blob URL // 创建Blob URL
const blobUrl = URL.createObjectURL(fileResponse.data) const blobUrl = URL.createObjectURL(fileResponse.data)
@@ -925,7 +925,7 @@ const submitCreateExam = async () => {
// 上传实验资源 // 上传实验资源
const uploadExamResources = async (examId: string) => { const uploadExamResources = async (examId: string) => {
const client = AuthManager.createAuthenticatedExamClient() const client = AuthManager.createAuthenticatedResourceClient()
try { try {
// 上传MD文档 // 上传MD文档
@@ -934,7 +934,7 @@ const uploadExamResources = async (examId: string) => {
data: uploadFiles.value.mdFile, data: uploadFiles.value.mdFile,
fileName: uploadFiles.value.mdFile.name fileName: uploadFiles.value.mdFile.name
} }
await client.addExamResource(examId, 'doc', mdFileParam) await client.addResource('doc', 'template', examId, mdFileParam)
console.log('MD文档上传成功') console.log('MD文档上传成功')
} }
@@ -944,7 +944,7 @@ const uploadExamResources = async (examId: string) => {
data: imageFile, data: imageFile,
fileName: imageFile.name fileName: imageFile.name
} }
await client.addExamResource(examId, 'image', imageFileParam) await client.addResource('image', 'template', examId, imageFileParam)
console.log('图片上传成功:', imageFile.name) console.log('图片上传成功:', imageFile.name)
} }
@@ -954,7 +954,7 @@ const uploadExamResources = async (examId: string) => {
data: bitstreamFile, data: bitstreamFile,
fileName: bitstreamFile.name fileName: bitstreamFile.name
} }
await client.addExamResource(examId, 'bitstream', bitstreamFileParam) await client.addResource('bitstream', 'template', examId, bitstreamFileParam)
console.log('比特流文件上传成功:', bitstreamFile.name) console.log('比特流文件上传成功:', bitstreamFile.name)
} }
@@ -964,7 +964,7 @@ const uploadExamResources = async (examId: string) => {
data: canvasFile, data: canvasFile,
fileName: canvasFile.name fileName: canvasFile.name
} }
await client.addExamResource(examId, 'canvas', canvasFileParam) await client.addResource('canvas', 'template', examId, canvasFileParam)
console.log('画布模板上传成功:', canvasFile.name) console.log('画布模板上传成功:', canvasFile.name)
} }
@@ -974,7 +974,7 @@ const uploadExamResources = async (examId: string) => {
data: uploadFiles.value.resourceFile, data: uploadFiles.value.resourceFile,
fileName: uploadFiles.value.resourceFile.name fileName: uploadFiles.value.resourceFile.name
} }
await client.addExamResource(examId, 'resource', resourceFileParam) await client.addResource('resource', 'template', examId, resourceFileParam)
console.log('资源包上传成功') console.log('资源包上传成功')
} }

View File

@@ -31,8 +31,8 @@
:checked="checkID === 3" :checked="checkID === 3"
@change="handleTabChange" @change="handleTabChange"
/> />
<SquareActivityIcon class="icon" /> <Monitor class="icon" />
示波器 HDMI视频流
</label> </label>
<label class="tab"> <label class="tab">
<input <input
@@ -42,8 +42,8 @@
:checked="checkID === 4" :checked="checkID === 4"
@change="handleTabChange" @change="handleTabChange"
/> />
<Binary class="icon" /> <SquareActivityIcon class="icon" />
逻辑分析仪 示波器
</label> </label>
<label class="tab"> <label class="tab">
<input <input
@@ -53,6 +53,17 @@
:checked="checkID === 5" :checked="checkID === 5"
@change="handleTabChange" @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" /> <Hand class="icon" />
嵌入式逻辑分析仪 嵌入式逻辑分析仪
</label> </label>
@@ -73,12 +84,15 @@
<VideoStreamView /> <VideoStreamView />
</div> </div>
<div v-else-if="checkID === 3" class="h-full overflow-y-auto"> <div v-else-if="checkID === 3" class="h-full overflow-y-auto">
<OscilloscopeView /> <HdmiVideoStreamView />
</div> </div>
<div v-else-if="checkID === 4" class="h-full overflow-y-auto"> <div v-else-if="checkID === 4" class="h-full overflow-y-auto">
<LogicAnalyzerView /> <OscilloscopeView />
</div> </div>
<div v-else-if="checkID === 5" class="h-full overflow-y-auto"> <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 /> <Debugger />
</div> </div>
</div> </div>
@@ -94,9 +108,11 @@ import {
MinimizeIcon, MinimizeIcon,
Binary, Binary,
Hand, Hand,
Monitor,
} from "lucide-vue-next"; } from "lucide-vue-next";
import { useLocalStorage } from "@vueuse/core"; import { useLocalStorage } from "@vueuse/core";
import VideoStreamView from "@/views/Project/VideoStream.vue"; import VideoStreamView from "@/views/Project/VideoStream.vue";
import HdmiVideoStreamView from "@/views/Project/HdmiVideoStream.vue";
import OscilloscopeView from "@/views/Project/Oscilloscope.vue"; import OscilloscopeView from "@/views/Project/Oscilloscope.vue";
import LogicAnalyzerView from "@/views/Project/LogicAnalyzer.vue"; import LogicAnalyzerView from "@/views/Project/LogicAnalyzer.vue";
import { isNull, toNumber } from "lodash"; import { isNull, toNumber } from "lodash";

View File

@@ -0,0 +1,490 @@
<template>
<div class="bg-base-100 flex flex-col gap-7">
<!-- 控制面板 -->
<div class="card bg-base-200 shadow-xl mx-5">
<div class="card-body">
<h2 class="card-title text-primary">
<Settings class="w-6 h-6" />
HDMI视频流控制面板
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- 板卡信息 -->
<div class="stats shadow">
<div class="stat bg-base-100">
<div class="stat-figure text-primary">
<div class="badge" :class="endpoint ? 'badge-success' : 'badge-warning'">
{{ endpoint ? "已连接" : "未配置" }}
</div>
</div>
<div class="stat-title">板卡状态</div>
<div class="stat-value text-primary">HDMI</div>
<div class="stat-desc">{{ endpoint ? `板卡: ${endpoint.boardId.substring(0, 8)}...` : "请先连接板卡" }}</div>
</div>
</div>
<!-- 连接状态 -->
<div class="stats shadow">
<div class="stat bg-base-100">
<div class="stat-figure text-secondary">
<Video class="w-8 h-8" />
</div>
<div class="stat-title">视频状态</div>
<div class="stat-value text-secondary">
{{ isPlaying ? "播放中" : "未播放" }}
</div>
<div class="stat-desc">{{ videoStatus }}</div>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="card-actions justify-end mt-4">
<button class="btn btn-outline btn-primary" @click="refreshEndpoint" :disabled="loading">
<RefreshCw v-if="loading" class="animate-spin h-4 w-4 mr-2" />
<RefreshCw v-else class="h-4 w-4 mr-2" />
{{ loading ? "刷新中..." : "刷新连接" }}
</button>
<button class="btn btn-primary" @click="testConnection" :disabled="testing || !endpoint">
<RefreshCw v-if="testing" class="animate-spin h-4 w-4 mr-2" />
<TestTube v-else class="h-4 w-4 mr-2" />
{{ testing ? "测试中..." : "测试连接" }}
</button>
</div>
</div>
</div>
<!-- 视频预览区域 -->
<div class="card bg-base-200 shadow-xl mx-5">
<div class="card-body">
<h2 class="card-title text-primary">
<Video class="w-6 h-6" />
HDMI视频预览
</h2>
<div class="relative bg-black rounded-lg overflow-hidden cursor-pointer" :class="[
{ 'cursor-not-allowed': !isPlaying || hasVideoError || !endpoint }
]" style="aspect-ratio: 16/9" @click="handleVideoClick">
<!-- 视频播放器 - 使用img标签直接显示MJPEG流 -->
<div v-show="isPlaying && endpoint" class="w-full h-full flex items-center justify-center">
<img :src="currentVideoSource" alt="HDMI视频流" class="max-w-full max-h-full object-contain"
@error="handleVideoError" @load="handleVideoLoad" />
</div>
<!-- 错误信息显示 -->
<div v-if="hasVideoError" class="absolute inset-0 flex items-center justify-center bg-black bg-opacity-70">
<div class="card bg-error text-white shadow-lg w-full max-w-lg">
<div class="card-body">
<h3 class="card-title flex items-center gap-2">
<AlertTriangle class="h-6 w-6" />
HDMI视频流加载失败
</h3>
<p>无法连接到HDMI视频服务器请检查以下内容</p>
<ul class="list-disc list-inside">
<li>HDMI输入设备是否已连接</li>
<li>板卡是否正常工作</li>
<li>网络连接是否正常</li>
<li>HDMI视频流服务是否已启动</li>
</ul>
<div class="card-actions justify-end mt-2">
<button class="btn btn-sm btn-outline btn-primary" @click="tryReconnect">
重试连接
</button>
</div>
</div>
</div>
</div>
<!-- 占位符 -->
<div v-show="(!isPlaying && !hasVideoError) || !endpoint"
class="absolute inset-0 flex items-center justify-center text-white">
<div class="text-center">
<Video class="w-16 h-16 mx-auto mb-4 opacity-50" />
<p class="text-lg opacity-75">{{ videoStatus }}</p>
<p class="text-sm opacity-60 mt-2">
{{ endpoint ? '点击"播放HDMI视频流"按钮开始查看实时视频' : '请先刷新连接以获取板卡信息' }}
</p>
</div>
</div>
</div>
<!-- 视频控制 -->
<div class="flex justify-between items-center mt-4" v-if="endpoint">
<div class="text-sm text-base-content/70">
MJPEG地址:
<code class="bg-base-300 px-2 py-1 rounded text-xs">{{
endpoint.mjpegUrl
}}</code>
</div>
<div class="space-x-2">
<div class="dropdown dropdown-hover dropdown-top dropdown-end">
<div tabindex="0" role="button" class="btn btn-sm btn-outline btn-accent">
<MoreHorizontal class="w-4 h-4 mr-1" />
更多功能
</div>
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-52">
<li>
<a @click="openInNewTab(endpoint.videoUrl)">
<ExternalLink class="w-4 h-4" />
在新标签打开视频页面
</a>
</li>
<li>
<a @click="takeSnapshot">
<Camera class="w-4 h-4" />
获取并下载快照
</a>
</li>
<li>
<a @click="copyToClipboard(endpoint.mjpegUrl)">
<Copy class="w-4 h-4" />
复制MJPEG地址
</a>
</li>
</ul>
</div>
<button class="btn btn-success btn-sm" @click="startStream" :disabled="isPlaying || !endpoint">
<Play class="w-4 h-4 mr-1" />
播放HDMI视频流
</button>
<button class="btn btn-error btn-sm" @click="stopStream" :disabled="!isPlaying">
<Square class="w-4 h-4 mr-1" />
停止视频流
</button>
</div>
</div>
</div>
</div>
<!-- 日志区域 -->
<div class="card bg-base-200 shadow-xl mx-5">
<div class="card-body">
<h2 class="card-title text-primary">
<FileText class="w-6 h-6" />
操作日志
</h2>
<div class="bg-base-300 rounded-lg p-4 h-48 overflow-y-auto">
<div v-for="(log, index) in logs" :key="index" class="text-sm font-mono mb-1">
<span class="text-base-content/50">[{{ formatTime(log.time) }}]</span>
<span :class="getLogClass(log.level)">{{ log.message }}</span>
</div>
<div v-if="logs.length === 0" class="text-base-content/50 text-center py-8">
暂无日志记录
</div>
</div>
<div class="card-actions justify-end mt-2">
<button class="btn btn-outline btn-sm" @click="clearLogs">
清空日志
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import {
Settings,
Video,
RefreshCw,
TestTube,
Play,
Square,
ExternalLink,
Camera,
Copy,
FileText,
AlertTriangle,
MoreHorizontal,
} from "lucide-vue-next";
import { HdmiVideoStreamClient, type HdmiVideoStreamEndpoint } from "@/APIClient";
import { AuthManager } from "@/utils/AuthManager";
import { useAlertStore } from "@/components/Alert";
// Alert系统
const alert = useAlertStore();
// 状态管理
const loading = ref(false);
const testing = ref(false);
const isPlaying = ref(false);
const hasVideoError = ref(false);
const videoStatus = ref('未连接');
// HDMI视频流数据
const endpoint = ref<HdmiVideoStreamEndpoint | null>(null);
const currentVideoSource = ref('');
// 日志系统
interface LogEntry {
time: Date;
level: 'info' | 'success' | 'warning' | 'error';
message: string;
}
const logs = ref<LogEntry[]>([]);
// 添加日志
function addLog(level: LogEntry['level'], message: string) {
logs.value.unshift({
time: new Date(),
level,
message
});
// 保持最近100条日志
if (logs.value.length > 100) {
logs.value = logs.value.slice(0, 100);
}
}
// 格式化时间
function formatTime(date: Date): string {
return date.toLocaleTimeString();
}
// 获取日志样式类
function getLogClass(level: LogEntry['level']): string {
switch (level) {
case 'success':
return 'text-success';
case 'warning':
return 'text-warning';
case 'error':
return 'text-error';
default:
return 'text-base-content';
}
}
// 清空日志
function clearLogs() {
logs.value = [];
addLog('info', '日志已清空');
}
// 刷新HDMI视频流端点
async function refreshEndpoint() {
loading.value = true;
try {
addLog('info', '正在获取HDMI视频流端点...');
const client = AuthManager.createAuthenticatedHdmiVideoStreamClient();
const result = await client.getMyEndpoint();
if (result) {
endpoint.value = result;
videoStatus.value = '已连接板卡,可以播放视频流';
addLog('success', `成功获取HDMI视频流端点板卡ID: ${result.boardId.substring(0, 8)}...`);
alert?.success('HDMI视频流连接成功');
} else {
endpoint.value = null;
videoStatus.value = '无法获取板卡信息';
addLog('error', '未找到绑定的板卡或板卡未配置HDMI输入');
alert?.error('未找到绑定的板卡');
}
} catch (error) {
console.error('获取HDMI视频流端点失败:', error);
endpoint.value = null;
videoStatus.value = '连接失败';
addLog('error', `获取HDMI视频流端点失败: ${error}`);
alert?.error('获取HDMI视频流信息失败');
} finally {
loading.value = false;
}
}
// 测试连接
async function testConnection() {
if (!endpoint.value) {
alert?.warn('请先刷新连接获取板卡信息');
return;
}
testing.value = true;
try {
addLog('info', '正在测试HDMI视频流连接...');
// 尝试获取快照来测试连接
const response = await fetch(endpoint.value.snapshotUrl, {
method: 'GET',
headers: {
'Cache-Control': 'no-cache'
}
});
if (response.ok) {
addLog('success', 'HDMI视频流连接测试成功');
alert?.success('HDMI连接测试成功');
videoStatus.value = '连接正常,可以播放视频流';
} else {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
} catch (error) {
console.error('HDMI视频流连接测试失败:', error);
addLog('error', `连接测试失败: ${error}`);
alert?.error('HDMI连接测试失败');
videoStatus.value = '连接测试失败';
} finally {
testing.value = false;
}
}
// 开始播放视频流
function startStream() {
if (!endpoint.value) {
alert?.warn('请先刷新连接获取板卡信息');
return;
}
try {
// 添加时间戳防止缓存
const timestamp = new Date().getTime();
currentVideoSource.value = `${endpoint.value.mjpegUrl}&t=${timestamp}`;
isPlaying.value = true;
hasVideoError.value = false;
videoStatus.value = '正在加载视频流...';
addLog('info', '开始播放HDMI视频流');
alert?.success('开始播放HDMI视频流');
} catch (error) {
console.error('启动HDMI视频流失败:', error);
addLog('error', `启动视频流失败: ${error}`);
alert?.error('启动HDMI视频流失败');
}
}
// 停止播放视频流
function stopStream() {
isPlaying.value = false;
currentVideoSource.value = '';
videoStatus.value = '已停止播放';
const client = AuthManager.createAuthenticatedHdmiVideoStreamClient();
client.disableHdmiTransmission();
addLog('info', '停止播放HDMI视频流');
alert?.info('已停止播放HDMI视频流');
}
// 处理视频加载错误
function handleVideoError() {
hasVideoError.value = true;
videoStatus.value = '视频流加载失败';
addLog('error', 'HDMI视频流加载失败');
}
// 处理视频加载成功
function handleVideoLoad() {
hasVideoError.value = false;
videoStatus.value = '视频流播放中';
addLog('success', 'HDMI视频流加载成功');
}
// 处理视频点击
function handleVideoClick() {
if (!isPlaying.value || hasVideoError.value || !endpoint.value) {
return;
}
// 可以在这里添加点击视频的交互逻辑
addLog('info', '视频画面被点击');
}
// 重试连接
function tryReconnect() {
hasVideoError.value = false;
if (endpoint.value) {
startStream();
}
}
// 在新标签页打开视频
function openInNewTab(url: string) {
window.open(url, '_blank');
addLog('info', '在新标签页打开HDMI视频页面');
}
// 获取快照
async function takeSnapshot() {
if (!endpoint.value) {
alert?.warn('请先刷新连接获取板卡信息');
return;
}
try {
addLog('info', '正在获取HDMI视频快照...');
const response = await fetch(endpoint.value.snapshotUrl, {
method: 'GET',
headers: {
'Cache-Control': 'no-cache'
}
});
if (response.ok) {
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `hdmi_snapshot_${new Date().toISOString().replace(/:/g, '-')}.jpg`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
addLog('success', '快照下载成功');
alert?.success('HDMI快照下载成功');
} else {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
} catch (error) {
console.error('获取HDMI快照失败:', error);
addLog('error', `获取快照失败: ${error}`);
alert?.error('获取HDMI快照失败');
}
}
// 复制到剪贴板
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text);
addLog('success', '地址已复制到剪贴板');
alert?.success('地址已复制到剪贴板');
} catch (error) {
console.error('复制到剪贴板失败:', error);
addLog('error', '复制到剪贴板失败');
alert?.error('复制到剪贴板失败');
}
}
// 组件挂载时初始化
onMounted(() => {
addLog('info', 'HDMI视频流界面已初始化');
refreshEndpoint();
});
// 组件卸载时清理
onUnmounted(() => {
stopStream();
});
</script>
<style scoped>
/* 对焦动画效果 */
@keyframes focus-pulse {
0%, 100% {
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.7);
}
50% {
box-shadow: 0 0 0 10px rgba(59, 130, 246, 0);
}
}
.focus-animation {
animation: focus-pulse 1s ease-out;
}
</style>

View File

@@ -37,7 +37,7 @@
<!-- 拖拽分割线 --> <!-- 拖拽分割线 -->
<SplitterResizeHandle <SplitterResizeHandle
id="splitter-group-h-resize-handle" id="splitter-group-h-resize-handle"
class="w-2 bg-base-100 hover:bg-primary hover:opacity-70 transition-colors" class="w-1 bg-base-300"
/> />
<!-- 右侧编辑区域 --> <!-- 右侧编辑区域 -->
<SplitterPanel <SplitterPanel
@@ -74,7 +74,7 @@
<SplitterResizeHandle <SplitterResizeHandle
v-show="!isBottomBarFullscreen" v-show="!isBottomBarFullscreen"
id="splitter-group-v-resize-handle" id="splitter-group-v-resize-handle"
class="h-2 bg-base-100 hover:bg-primary hover:opacity-70 transition-colors" class="h-1 bg-base-300"
/> />
<!-- 功能底栏 --> <!-- 功能底栏 -->
@@ -217,17 +217,17 @@ async function loadDocumentContent() {
if (examId) { if (examId) {
// 如果有实验ID从API加载实验文档 // 如果有实验ID从API加载实验文档
console.log('加载实验文档:', examId); console.log('加载实验文档:', examId);
const client = AuthManager.createAuthenticatedExamClient(); const client = AuthManager.createAuthenticatedResourceClient();
// 获取markdown类型的资源列表 // 获取markdown类型的模板资源列表
const resources = await client.getExamResourceList(examId, 'doc'); const resources = await client.getResourceList(examId, 'doc', 'template');
if (resources && resources.length > 0) { if (resources && resources.length > 0) {
// 获取第一个markdown资源 // 获取第一个markdown资源
const markdownResource = resources[0]; const markdownResource = resources[0];
// 使用动态API获取资源文件内容 // 使用新的ResourceClient API获取资源文件内容
const response = await client.getExamResourceById(markdownResource.id); const response = await client.getResourceById(markdownResource.id);
if (!response || !response.data) { if (!response || !response.data) {
throw new Error('获取markdown文件失败'); throw new Error('获取markdown文件失败');

View File

@@ -61,13 +61,6 @@
<Settings class="w-5 h-5" /> <Settings class="w-5 h-5" />
触发设置 触发设置
</div> </div>
<!-- 配置摘要 -->
<div class="flex items-center gap-4 text-sm text-gray-500">
<span>{{ analyzer.enabledChannelCount.value }}/32 通道</span>
<span>捕获: {{ analyzer.captureLength.value }}</span>
<span>预捕获: {{ analyzer.preCaptureLength.value }}</span>
<span>{{ analyzer.globalModes.find(m => m.value === analyzer.currentGlobalMode.value)?.label || '未知' }}</span>
</div>
</div> </div>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<!-- 状态指示 --> <!-- 状态指示 -->