feat(fanuc): 添加协议编解码与状态页" -m "* 固化 10010 状态帧、10012 命令帧和 60015 J519 包编解码

* 扩展 ControllerClient 兼容层的执行参数和运行时编排
  * 新增 /status 页面与 /api/status/snapshot 状态快照接口
  * 补充 FANUC 协议、客户端和状态接口的最小验证测试
  * 更新 README、兼容要求和真机 Socket 通信实现计划
This commit is contained in:
2026-04-24 21:26:25 +08:00
parent 8a20d9f507
commit a78e6761cb
25 changed files with 3773 additions and 55 deletions

View File

@@ -0,0 +1,385 @@
using Flyshot.ControllerClientCompat;
using Microsoft.AspNetCore.Mvc;
namespace Flyshot.Server.Host.Controllers;
/// <summary>
/// 提供只读状态监控页面和控制器状态快照 API。
/// </summary>
[ApiController]
public sealed class StatusController : ControllerBase
{
/// <summary>
/// 浏览器端状态监控页面,页面逻辑只读取状态快照,不触发控制动作。
/// </summary>
private const string StatusPageHtml = """
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Flyshot Replacement </title>
<style>
:root {
color-scheme: light;
--bg: #f5f7fb;
--surface: #ffffff;
--line: #d8dee9;
--text: #172033;
--muted: #5b667a;
--accent: #007c89;
--good: #12805c;
--warn: #b7791f;
--bad: #b42318;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: "Segoe UI", "Microsoft YaHei", Arial, sans-serif;
font-size: 15px;
letter-spacing: 0;
}
header {
border-bottom: 1px solid var(--line);
background: var(--surface);
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
width: min(1180px, calc(100% - 32px));
margin: 0 auto;
padding: 18px 0;
}
h1 {
margin: 0;
font-size: 22px;
font-weight: 650;
}
button {
min-height: 36px;
padding: 0 14px;
border: 1px solid var(--accent);
border-radius: 6px;
background: var(--accent);
color: #ffffff;
font: inherit;
cursor: pointer;
}
button:disabled {
opacity: 0.6;
cursor: default;
}
main {
width: min(1180px, calc(100% - 32px));
margin: 22px auto;
}
.summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.metric,
section {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface);
}
.metric {
min-height: 86px;
padding: 14px;
}
.label {
color: var(--muted);
font-size: 13px;
}
.value {
margin-top: 8px;
overflow-wrap: anywhere;
font-size: 24px;
font-weight: 650;
}
.status-row {
display: flex;
align-items: center;
gap: 8px;
}
.dot {
width: 12px;
height: 12px;
flex: 0 0 12px;
border-radius: 999px;
background: var(--warn);
}
.dot.good {
background: var(--good);
}
.dot.bad {
background: var(--bad);
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
section h2 {
margin: 0;
padding: 14px 16px;
border-bottom: 1px solid var(--line);
font-size: 16px;
font-weight: 650;
}
dl {
display: grid;
grid-template-columns: 160px minmax(0, 1fr);
gap: 0;
margin: 0;
padding: 4px 16px 12px;
}
dt,
dd {
min-height: 36px;
margin: 0;
padding: 9px 0;
border-bottom: 1px solid #edf1f7;
}
dt {
color: var(--muted);
}
dd {
overflow-wrap: anywhere;
font-family: Consolas, "Cascadia Mono", monospace;
}
.empty {
color: var(--muted);
font-family: inherit;
}
@media (max-width: 820px) {
.topbar {
align-items: flex-start;
flex-direction: column;
}
.summary,
.grid {
grid-template-columns: 1fr;
}
dl {
grid-template-columns: 1fr;
}
dt {
border-bottom: 0;
padding-bottom: 2px;
}
dd {
padding-top: 2px;
}
}
</style>
</head>
<body>
<header>
<div class="topbar">
<h1>Flyshot Replacement </h1>
<button id="refresh" type="button"></button>
</div>
</header>
<main>
<div class="summary">
<div class="metric">
<div class="label"></div>
<div class="value status-row"><span id="state-dot" class="dot"></span><span id="connection-state">--</span></div>
</div>
<div class="metric">
<div class="label"></div>
<div id="robot-name" class="value">--</div>
</div>
<div class="metric">
<div class="label"></div>
<div id="speed-ratio" class="value">--</div>
</div>
<div class="metric">
<div class="label"></div>
<div id="motion-state" class="value">--</div>
</div>
</div>
<div class="grid">
<section>
<h2></h2>
<dl>
<dt></dt><dd id="server-version">--</dd>
<dt></dt><dd id="client-version">--</dd>
<dt></dt><dd id="setup-state">--</dd>
<dt>使</dt><dd id="enabled-state">--</dd>
<dt></dt><dd id="captured-at">--</dd>
</dl>
</section>
<section>
<h2></h2>
<dl>
<dt></dt><dd id="dof">--</dd>
<dt></dt><dd id="joints">--</dd>
<dt>TCP 姿</dt><dd id="pose">--</dd>
<dt></dt><dd id="trajectories" class="empty">--</dd>
</dl>
</section>
</div>
</main>
<script>
const fields = {
connectionState: document.getElementById("connection-state"),
stateDot: document.getElementById("state-dot"),
robotName: document.getElementById("robot-name"),
speedRatio: document.getElementById("speed-ratio"),
motionState: document.getElementById("motion-state"),
serverVersion: document.getElementById("server-version"),
clientVersion: document.getElementById("client-version"),
setupState: document.getElementById("setup-state"),
enabledState: document.getElementById("enabled-state"),
capturedAt: document.getElementById("captured-at"),
dof: document.getElementById("dof"),
joints: document.getElementById("joints"),
pose: document.getElementById("pose"),
trajectories: document.getElementById("trajectories"),
refresh: document.getElementById("refresh")
};
function formatArray(values) {
if (!Array.isArray(values) || values.length === 0) {
return "--";
}
return values.map(value => Number(value).toFixed(4)).join(", ");
}
function setDot(connectionState) {
fields.stateDot.className = "dot";
if (connectionState === "Connected") {
fields.stateDot.classList.add("good");
} else if (connectionState === "NotConfigured") {
fields.stateDot.classList.add("bad");
}
}
async function refreshStatus() {
fields.refresh.disabled = true;
try {
const response = await fetch("/api/status/snapshot", { cache: "no-store" });
const payload = await response.json();
const snapshot = payload.snapshot;
fields.connectionState.textContent = snapshot.connectionState;
fields.robotName.textContent = payload.robotName || "--";
fields.speedRatio.textContent = Number(snapshot.speedRatio).toFixed(2);
fields.motionState.textContent = snapshot.isInMotion ? "是" : "否";
fields.serverVersion.textContent = payload.serverVersion;
fields.clientVersion.textContent = payload.clientVersion;
fields.setupState.textContent = payload.isSetup ? "是" : "否";
fields.enabledState.textContent = snapshot.isEnabled ? "是" : "否";
fields.capturedAt.textContent = new Date(snapshot.capturedAt).toLocaleString();
fields.dof.textContent = payload.degreesOfFreedom;
fields.joints.textContent = formatArray(snapshot.jointPositions);
fields.pose.textContent = formatArray(snapshot.cartesianPose);
fields.trajectories.textContent = payload.uploadedTrajectories.length > 0
? payload.uploadedTrajectories.join(", ")
: "--";
fields.trajectories.classList.toggle("empty", payload.uploadedTrajectories.length === 0);
setDot(snapshot.connectionState);
} finally {
fields.refresh.disabled = false;
}
}
fields.refresh.addEventListener("click", refreshStatus);
refreshStatus();
window.setInterval(refreshStatus, 2000);
</script>
</body>
</html>
""";
private readonly IControllerClientCompatService _compatService;
/// <summary>
/// 初始化状态监控控制器。
/// </summary>
/// <param name="compatService">ControllerClient 兼容层服务。</param>
public StatusController(IControllerClientCompatService compatService)
{
_compatService = compatService ?? throw new ArgumentNullException(nameof(compatService));
}
/// <summary>
/// 返回浏览器可直接打开的状态监控页面。
/// </summary>
/// <returns>HTML 状态页面。</returns>
[HttpGet("/status")]
public ContentResult GetStatusPage()
{
return Content(StatusPageHtml, "text/html; charset=utf-8");
}
/// <summary>
/// 返回当前 ControllerClient 兼容层与控制器运行时状态快照。
/// </summary>
/// <returns>面向状态页和外部诊断的 JSON 快照。</returns>
[HttpGet("/api/status/snapshot")]
public IActionResult GetSnapshot()
{
var snapshot = _compatService.GetControllerSnapshot();
var isSetup = _compatService.IsSetUp;
// 状态页需要在机器人未初始化时仍能打开,因此只有初始化后才读取机器人元数据。
var robotName = isSetup ? _compatService.GetRobotName() : null;
var degreesOfFreedom = isSetup ? _compatService.GetDegreesOfFreedom() : 0;
var uploadedTrajectories = isSetup ? _compatService.ListTrajectoryNames() : Array.Empty<string>();
return Ok(new
{
Status = "ok",
Service = "flyshot-server-host",
ServerVersion = _compatService.GetServerVersion(),
ClientVersion = _compatService.GetClientVersion(),
IsSetup = isSetup,
RobotName = robotName,
DegreesOfFreedom = degreesOfFreedom,
UploadedTrajectories = uploadedTrajectories,
Snapshot = snapshot
});
}
}