diff --git a/CHANGELOG.md b/CHANGELOG.md index 99e574d..1a2993b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,14 @@ ### 新增 +- 增加本机 SecsFrame.CommunicationDemo 通讯测试工具,提供 + Active/Passive 显式连接配置、真实 TCP 回环、严格 SML 编辑与样例、 + 数据/控制消息操作,以及最多 500 条的会话内活动和结构化诊断视图。 +- 增加独立 SecsFrame.GuidedDemo 分步成果演示,以固定输入逐步 + 执行真实 Select、动态 Item/SML、Primary/Secondary、Linktest 和 Separate + 后新会话恢复,并保留每一步的可见证据。 +- 增加 Demo 注意力模型、工程边界、每会话最多两轮的迭代账本和跨会话接续 + 约定;工程回环与固定脚本均不声明设备 Profile 或 SEMI 一致性。 - 增加独立 SecsFrame.Soak 可执行程序,在真实 TCP 待处理事务中 按显式随机种子选择双方 Separate 或端点销毁重建,恢复 Selected 后立即 验证新事务;普通 dotnet test 不执行该长时负载。 diff --git a/README.md b/README.md index e171009..555b7a2 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,8 @@ Item 用法与边界见 [docs/HSMS-PRIMARY-ROUTING.md](docs/HSMS-PRIMARY-ROUTING.md), Host/Equipment 端点见 [docs/SECS-ENDPOINT-ROLES.md](docs/SECS-ENDPOINT-ROLES.md), +本机通讯工具、分步成果演示及跨会话迭代接续见 +[docs/DEMOS.md](docs/DEMOS.md), 独立 GEM 基础行为见 [docs/GEM-FOUNDATION.md](docs/GEM-FOUNDATION.md), SML 调试文本见 @@ -124,6 +126,24 @@ dotnet build SecsFrame.slnx -c Release dotnet test SecsFrame.slnx -c Release --no-build ``` +## Demo + +通讯测试工具: + +```bash +dotnet run --project demo/SecsFrame.CommunicationDemo/SecsFrame.CommunicationDemo.csproj +``` + +分步成果演示: + +```bash +dotnet run --project demo/SecsFrame.GuidedDemo/SecsFrame.GuidedDemo.csproj +``` + +两个 Demo 均为本机 Interactive Server 应用。默认分别使用 + 与 ;工程回环和固定脚本 +不代表设备消息 Profile、标准合规或生产就绪。 + ## 许可证 [MIT](LICENSE) diff --git a/SecsFrame.slnx b/SecsFrame.slnx index 2283f00..55cc3f5 100644 --- a/SecsFrame.slnx +++ b/SecsFrame.slnx @@ -1,4 +1,8 @@ + + + + diff --git a/demo/SecsFrame.CommunicationDemo/Components/App.razor b/demo/SecsFrame.CommunicationDemo/Components/App.razor new file mode 100644 index 0000000..e78cd1d --- /dev/null +++ b/demo/SecsFrame.CommunicationDemo/Components/App.razor @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/demo/SecsFrame.CommunicationDemo/Components/Layout/MainLayout.razor b/demo/SecsFrame.CommunicationDemo/Components/Layout/MainLayout.razor new file mode 100644 index 0000000..4f3f76d --- /dev/null +++ b/demo/SecsFrame.CommunicationDemo/Components/Layout/MainLayout.razor @@ -0,0 +1,3 @@ +@inherits LayoutComponentBase + +@Body diff --git a/demo/SecsFrame.CommunicationDemo/Components/Pages/Home.razor b/demo/SecsFrame.CommunicationDemo/Components/Pages/Home.razor new file mode 100644 index 0000000..7ac07cb --- /dev/null +++ b/demo/SecsFrame.CommunicationDemo/Components/Pages/Home.razor @@ -0,0 +1,272 @@ +@page "/" +@implements IDisposable +@inject CommunicationWorkspace Workspace + +SecsFrame 通讯测试工具 + +
+
+
+ +
+

通讯测试工具

+

SecsFrame 工程演示

+
+
+
+ + @Workspace.State + @Workspace.Endpoint +
+
+ +
+
+
+
+ 01 +

连接

+
+
+ + + + + + + +
+ + + + +
+ +
+ 计时参数 +
+ + + + + +
+
+ +
+ @if (Workspace.IsConnected) + { + + } + else + { + + } +
+
+
+ +
+
+
+ 02 +

消息

+
+ @EditorStatus +
+ + + + + + @if (!string.IsNullOrEmpty(_operationError)) + { + + } + +
+ + + +
+
+ +
+
+
+ 03 +

活动与诊断

+
+ +
+ +
+ @if (Workspace.Activities.Count == 0) + { +
+ 暂无活动 + 连接状态、消息和诊断将在这里出现 +
+ } + else + { +
    + @foreach (var entry in Workspace.Activities) + { +
  1. +
    + @entry.Timestamp.ToString("HH:mm:ss.fff") + @entry.Category +
    + @entry.Title +

    @entry.Summary

    + @if (!string.IsNullOrEmpty(entry.Detail)) + { +
    + 查看详情 +
    @entry.Detail
    +
    + } +
  2. + } +
+ } +
+
+
+
+ +@code { + private readonly ConnectionDraft _draft = new(); + private string _selectedTemplate = MessageTemplate.All[0].Id; + private string _sml = MessageTemplate.All[0].Sml; + private string? _operationError; + + private bool CanOperate => + Workspace.State == HsmsSessionState.Selected && + !Workspace.IsBusy; + + private string EditorStatus => Workspace.State == HsmsSessionState.Selected + ? "可发送" + : "等待 Selected"; + + protected override void OnInitialized() + => Workspace.Changed += HandleWorkspaceChanged; + + public void Dispose() + => Workspace.Changed -= HandleWorkspaceChanged; + + private async Task ConnectAsync() + => await RunUiOperationAsync(() => Workspace.ConnectAsync(_draft)); + + private async Task DisconnectAsync() + => await RunUiOperationAsync(Workspace.DisconnectAsync); + + private async Task SendAsync() + => await RunUiOperationAsync(() => Workspace.SendAsync(_sml)); + + private async Task LinktestAsync() + => await RunUiOperationAsync(Workspace.LinktestAsync); + + private async Task SeparateAsync() + => await RunUiOperationAsync(Workspace.SeparateAsync); + + private void SelectTemplate(ChangeEventArgs args) + { + _selectedTemplate = args.Value?.ToString() ?? + MessageTemplate.All[0].Id; + var template = MessageTemplate.All + .Single(item => item.Id == _selectedTemplate); + _sml = template.Sml; + _operationError = null; + } + + private async Task RunUiOperationAsync(Func operation) + { + _operationError = null; + try + { + await operation(); + } + catch (Exception error) + { + _operationError = error.Message; + } + } + + private void HandleWorkspaceChanged(object? sender, EventArgs args) + => _ = InvokeAsync(StateHasChanged); +} diff --git a/demo/SecsFrame.CommunicationDemo/Components/Routes.razor b/demo/SecsFrame.CommunicationDemo/Components/Routes.razor new file mode 100644 index 0000000..2391331 --- /dev/null +++ b/demo/SecsFrame.CommunicationDemo/Components/Routes.razor @@ -0,0 +1,14 @@ + + + + + + + +
+

页面不存在

+ 返回通讯工具 +
+
+
+
diff --git a/demo/SecsFrame.CommunicationDemo/Components/_Imports.razor b/demo/SecsFrame.CommunicationDemo/Components/_Imports.razor new file mode 100644 index 0000000..fadc9f3 --- /dev/null +++ b/demo/SecsFrame.CommunicationDemo/Components/_Imports.razor @@ -0,0 +1,11 @@ +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using SecsFrame +@using SecsFrame.CommunicationDemo +@using SecsFrame.CommunicationDemo.Components +@using SecsFrame.CommunicationDemo.Components.Layout +@using SecsFrame.CommunicationDemo.Models +@using SecsFrame.CommunicationDemo.Services diff --git a/demo/SecsFrame.CommunicationDemo/Models/ActivityEntry.cs b/demo/SecsFrame.CommunicationDemo/Models/ActivityEntry.cs new file mode 100644 index 0000000..d047800 --- /dev/null +++ b/demo/SecsFrame.CommunicationDemo/Models/ActivityEntry.cs @@ -0,0 +1,10 @@ +namespace SecsFrame.CommunicationDemo.Models; + +internal sealed record ActivityEntry( + long Id, + DateTimeOffset Timestamp, + ActivityEntryTone Tone, + string Category, + string Title, + string Summary, + string? Detail); diff --git a/demo/SecsFrame.CommunicationDemo/Models/ActivityEntryTone.cs b/demo/SecsFrame.CommunicationDemo/Models/ActivityEntryTone.cs new file mode 100644 index 0000000..3be591c --- /dev/null +++ b/demo/SecsFrame.CommunicationDemo/Models/ActivityEntryTone.cs @@ -0,0 +1,9 @@ +namespace SecsFrame.CommunicationDemo.Models; + +internal enum ActivityEntryTone +{ + Neutral, + Success, + Warning, + Error, +} diff --git a/demo/SecsFrame.CommunicationDemo/Models/ConnectionDraft.cs b/demo/SecsFrame.CommunicationDemo/Models/ConnectionDraft.cs new file mode 100644 index 0000000..3cf4562 --- /dev/null +++ b/demo/SecsFrame.CommunicationDemo/Models/ConnectionDraft.cs @@ -0,0 +1,62 @@ +using System.ComponentModel.DataAnnotations; +using System.Net; + +namespace SecsFrame.CommunicationDemo.Models; + +internal sealed class ConnectionDraft : IValidatableObject +{ + [Required] + public string IpAddress { get; set; } = "127.0.0.1"; + + [Range(1, 65535)] + public int Port { get; set; } = 5000; + + public HsmsConnectionMode ConnectionMode { get; set; } = + HsmsConnectionMode.Active; + + [Range(0, 65534)] + public int SessionId { get; set; } + + [Range(1, 3600)] + public int T3Seconds { get; set; } = 10; + + [Range(1, 3600)] + public int T5Seconds { get; set; } = 2; + + [Range(1, 3600)] + public int T6Seconds { get; set; } = 5; + + [Range(1, 3600)] + public int T7Seconds { get; set; } = 10; + + [Range(1, 3600)] + public int T8Seconds { get; set; } = 5; + + public bool UseLoopbackPeer { get; set; } = true; + + public IEnumerable Validate( + ValidationContext validationContext) + { + if (!IPAddress.TryParse(IpAddress, out _)) + { + yield return new ValidationResult( + "请输入有效的 IPv4 或 IPv6 地址。", + new[] { nameof(IpAddress) }); + } + } + + public ConnectionDraft Snapshot() + => new() + { + IpAddress = IpAddress, + Port = Port, + ConnectionMode = ConnectionMode, + SessionId = SessionId, + T3Seconds = T3Seconds, + T5Seconds = T5Seconds, + T6Seconds = T6Seconds, + T7Seconds = T7Seconds, + T8Seconds = T8Seconds, + UseLoopbackPeer = UseLoopbackPeer, + }; +} diff --git a/demo/SecsFrame.CommunicationDemo/Models/MessageTemplate.cs b/demo/SecsFrame.CommunicationDemo/Models/MessageTemplate.cs new file mode 100644 index 0000000..1465bd6 --- /dev/null +++ b/demo/SecsFrame.CommunicationDemo/Models/MessageTemplate.cs @@ -0,0 +1,34 @@ +namespace SecsFrame.CommunicationDemo.Models; + +internal sealed record MessageTemplate( + string Id, + string Name, + string Sml) +{ + public static IReadOnlyList All { get; } = + new[] + { + new MessageTemplate( + "empty-w", + "空 Body / 等待回复", + "'S1F1'W\n.\n"), + new MessageTemplate( + "nested-w", + "嵌套 Item / 等待回复", + "'S6F11'W\n" + + "\n" + + " \n" + + " \n" + + " >\n" + + ">\n" + + ".\n"), + new MessageTemplate( + "fire-forget", + "无回复消息", + "'S2F17'\n" + + "\n" + + ".\n"), + }; +} diff --git a/demo/SecsFrame.CommunicationDemo/Program.cs b/demo/SecsFrame.CommunicationDemo/Program.cs new file mode 100644 index 0000000..f551082 --- /dev/null +++ b/demo/SecsFrame.CommunicationDemo/Program.cs @@ -0,0 +1,26 @@ +using SecsFrame.CommunicationDemo.Components; +using SecsFrame.CommunicationDemo.Services; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services + .AddRazorComponents() + .AddInteractiveServerComponents(); +builder.Services.AddScoped(); + +var app = builder.Build(); + +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/error"); + app.UseHsts(); +} + +app.UseStaticFiles(); +app.UseAntiforgery(); + +app.MapGet("/favicon.ico", static () => Results.NoContent()); +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +app.Run(); diff --git a/demo/SecsFrame.CommunicationDemo/Properties/launchSettings.json b/demo/SecsFrame.CommunicationDemo/Properties/launchSettings.json new file mode 100644 index 0000000..59cd8d1 --- /dev/null +++ b/demo/SecsFrame.CommunicationDemo/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5080", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/demo/SecsFrame.CommunicationDemo/SecsFrame.CommunicationDemo.csproj b/demo/SecsFrame.CommunicationDemo/SecsFrame.CommunicationDemo.csproj new file mode 100644 index 0000000..22f6b3b --- /dev/null +++ b/demo/SecsFrame.CommunicationDemo/SecsFrame.CommunicationDemo.csproj @@ -0,0 +1,13 @@ + + + net8.0 + false + + + + + + + + + diff --git a/demo/SecsFrame.CommunicationDemo/Services/CommunicationWorkspace.cs b/demo/SecsFrame.CommunicationDemo/Services/CommunicationWorkspace.cs new file mode 100644 index 0000000..d0511c3 --- /dev/null +++ b/demo/SecsFrame.CommunicationDemo/Services/CommunicationWorkspace.cs @@ -0,0 +1,496 @@ +using System.Net; +using SecsFrame.CommunicationDemo.Models; +using SecsFrame.Sml; + +namespace SecsFrame.CommunicationDemo.Services; + +internal sealed class CommunicationWorkspace : IAsyncDisposable +{ + private const int ActivityLimit = 500; + private readonly SemaphoreSlim _operationGate = new(1, 1); + private readonly object _activityGate = new(); + private readonly List _activities = new(); + private readonly SmlMessageCodec _sml = new( + maxNestingDepth: 32, + maxItemCount: 10_000, + maxValueCount: 100_000, + maxTextLength: 1_000_000); + private CancellationTokenSource? _sessionCancellation; + private HsmsConnection? _connection; + private DemoLoopbackPeer? _loopbackPeer; + private Task? _eventPump; + private long _nextActivityId; + private int _disposed; + + public event EventHandler? Changed; + + public HsmsSessionState State { get; private set; } = + HsmsSessionState.Disconnected; + + public bool IsBusy { get; private set; } + + public bool IsConnected => _connection is not null; + + public string Endpoint { get; private set; } = "未配置"; + + public IReadOnlyList Activities + { + get + { + lock (_activityGate) + return _activities.ToArray(); + } + } + + public async Task ConnectAsync(ConnectionDraft source) + { + ArgumentNullException.ThrowIfNull(source); + ThrowIfDisposed(); + await _operationGate.WaitAsync().ConfigureAwait(false); + try + { + SetBusy(true); + await DisconnectCoreAsync(addActivity: false).ConfigureAwait(false); + await StartConnectionAsync(source.Snapshot()).ConfigureAwait(false); + } + catch (Exception error) + { + AddFailure("连接失败", error); + await DisconnectCoreAsync(addActivity: false).ConfigureAwait(false); + throw; + } + finally + { + SetBusy(false); + _operationGate.Release(); + } + } + + public async Task DisconnectAsync() + { + ThrowIfDisposed(); + await _operationGate.WaitAsync().ConfigureAwait(false); + try + { + SetBusy(true); + await DisconnectCoreAsync(addActivity: true).ConfigureAwait(false); + } + finally + { + SetBusy(false); + _operationGate.Release(); + } + } + + public async Task SendAsync(string text) + { + ThrowIfDisposed(); + await _operationGate.WaitAsync().ConfigureAwait(false); + try + { + SetBusy(true); + await SendCoreAsync(text).ConfigureAwait(false); + } + catch (Exception error) + { + AddFailure("发送失败", error); + throw; + } + finally + { + SetBusy(false); + _operationGate.Release(); + } + } + + public Task LinktestAsync() + => RunControlAsync( + "Linktest", + static connection => connection.LinktestAsync()); + + public Task SeparateAsync() + => RunControlAsync( + "Separate", + static connection => connection.SeparateAsync()); + + public void ClearActivities() + { + lock (_activityGate) + _activities.Clear(); + NotifyChanged(); + } + + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + return; + + await _operationGate.WaitAsync().ConfigureAwait(false); + try + { + await DisconnectCoreAsync(addActivity: false).ConfigureAwait(false); + } + finally + { + _operationGate.Release(); + _operationGate.Dispose(); + } + } + + private async Task StartConnectionAsync(ConnectionDraft draft) + { + var address = draft.UseLoopbackPeer + ? IPAddress.Loopback + : IPAddress.Parse(draft.IpAddress); + var mode = draft.UseLoopbackPeer + ? HsmsConnectionMode.Active + : draft.ConnectionMode; + var options = CreateOptions(draft, address, mode); + var cancellation = new CancellationTokenSource(); + _sessionCancellation = cancellation; + + if (draft.UseLoopbackPeer) + StartLoopbackPeer(draft); + + _connection = new HsmsConnection(options); + _connection.Start(); + _eventPump = PumpEventsAsync(_connection, cancellation.Token); + Endpoint = $"{options.ConnectionMode} " + + $"{options.IpAddress}:{options.Port} / Session {options.SessionId}"; + AddActivity( + ActivityEntryTone.Neutral, + "连接", + "连接已启动", + Endpoint, + null); + + using var selectionTimeout = CancellationTokenSource + .CreateLinkedTokenSource(cancellation.Token); + selectionTimeout.CancelAfter( + options.T7 + options.T6 + TimeSpan.FromSeconds(2)); + await _connection.WaitUntilSelectedAsync(selectionTimeout.Token) + .ConfigureAwait(false); + State = HsmsSessionState.Selected; + AddActivity( + ActivityEntryTone.Success, + "会话", + "会话已选择", + "可以发送数据消息或控制命令", + null); + } + + private void StartLoopbackPeer(ConnectionDraft draft) + { + _loopbackPeer = DemoLoopbackPeer.Start( + CreateOptions( + draft, + IPAddress.Loopback, + HsmsConnectionMode.Passive)); + _loopbackPeer.Failed += HandleLoopbackFailure; + AddActivity( + ActivityEntryTone.Neutral, + "回环端", + "本机回环端已启动", + $"127.0.0.1:{draft.Port}", + "回环端仅用于工程演示:收到 W-Bit 消息后返回同一 Body," + + "Function 加一;这不是设备消息 Profile。"); + } + + private async Task SendCoreAsync(string text) + { + var connection = RequireConnection(); + var message = _sml.Decode(text); + AddActivity( + ActivityEntryTone.Neutral, + "发送", + $"发送 S{message.Stream}F{message.Function}" + + (message.ReplyExpected ? " W" : string.Empty), + message.ReplyExpected ? "等待 Secondary" : "写出后完成", + _sml.Encode(message)); + + var secondary = await connection.SendAsync(message) + .ConfigureAwait(false); + if (secondary is null) + { + AddActivity( + ActivityEntryTone.Success, + "发送", + "消息写出完成", + "无 W-Bit,不等待回复", + null); + return; + } + + AddActivity( + ActivityEntryTone.Success, + "接收", + $"收到 S{secondary.Message.Stream}F" + + $"{secondary.Message.Function}", + $"System Bytes 0x{secondary.SystemBytes:X8}", + _sml.Encode(secondary.Message)); + } + + private async Task RunControlAsync( + string name, + Func operation) + { + ThrowIfDisposed(); + await _operationGate.WaitAsync().ConfigureAwait(false); + try + { + SetBusy(true); + var connection = RequireConnection(); + AddActivity( + ActivityEntryTone.Neutral, + "控制", + $"发送 {name}", + connection.State.ToString(), + null); + await operation(connection).ConfigureAwait(false); + AddActivity( + ActivityEntryTone.Success, + "控制", + $"{name} 完成", + connection.State.ToString(), + null); + } + catch (Exception error) + { + AddFailure($"{name} 失败", error); + throw; + } + finally + { + SetBusy(false); + _operationGate.Release(); + } + } + + private async Task PumpEventsAsync( + HsmsConnection connection, + CancellationToken cancellationToken) + { + try + { + await foreach (var item in connection + .GetEventsAsync(cancellationToken) + .ConfigureAwait(false)) + { + ProcessEvent(item); + } + } + catch (OperationCanceledException) + when (cancellationToken.IsCancellationRequested) + { + } + catch (Exception error) + { + AddFailure("事件流结束", error); + } + } + + private void ProcessEvent(HsmsConnectionEvent item) + { + State = item.State; + switch (item.Kind) + { + case HsmsConnectionEventKind.StateChanged: + AddStateActivity(item); + break; + case HsmsConnectionEventKind.DataMessageReceived: + AddIncomingActivity(item.IncomingMessage!.DataMessage); + break; + case HsmsConnectionEventKind.ControlMessageReceived: + AddActivity( + ActivityEntryTone.Warning, + "控制", + "收到未认领控制消息", + item.State.ToString(), + null); + break; + case HsmsConnectionEventKind.DataMessageDecodeFailed: + AddActivity( + ActivityEntryTone.Error, + "解码", + "数据消息解码失败", + item.Diagnostic?.Code.ToString() ?? + item.Error?.GetType().Name ?? + "未知错误", + FormatDiagnostic(item.Diagnostic)); + break; + default: + throw new ArgumentOutOfRangeException( + nameof(item), + item.Kind, + "Unknown connection event kind."); + } + } + + private void AddStateActivity(HsmsConnectionEvent item) + => AddActivity( + item.Error is null + ? ActivityEntryTone.Neutral + : ActivityEntryTone.Warning, + "状态", + $"状态变为 {item.State}", + item.Diagnostic?.Code.ToString() ?? + item.Error?.GetType().Name ?? + "正常转换", + FormatDiagnostic(item.Diagnostic)); + + private void AddIncomingActivity(HsmsDataMessage incoming) + => AddActivity( + ActivityEntryTone.Success, + "接收", + $"收到 S{incoming.Message.Stream}F" + + $"{incoming.Message.Function}" + + (incoming.Message.ReplyExpected ? " W" : string.Empty), + $"System Bytes 0x{incoming.SystemBytes:X8}", + _sml.Encode(incoming.Message)); + + private async Task DisconnectCoreAsync(bool addActivity) + { + var connection = _connection; + var peer = _loopbackPeer; + var cancellation = _sessionCancellation; + var eventPump = _eventPump; + + _connection = null; + _loopbackPeer = null; + _sessionCancellation = null; + _eventPump = null; + Endpoint = "未配置"; + State = HsmsSessionState.Disconnected; + + cancellation?.Cancel(); + if (connection is not null) + await connection.DisposeAsync().ConfigureAwait(false); + if (peer is not null) + { + peer.Failed -= HandleLoopbackFailure; + await peer.DisposeAsync().ConfigureAwait(false); + } + await ObserveCompletionAsync(eventPump).ConfigureAwait(false); + cancellation?.Dispose(); + + if (addActivity) + { + AddActivity( + ActivityEntryTone.Neutral, + "连接", + "连接已停止", + "资源已释放", + null); + } + else + { + NotifyChanged(); + } + } + + private HsmsConnection RequireConnection() + => _connection ?? + throw new InvalidOperationException("请先建立连接。"); + + private static HsmsConnectionOptions CreateOptions( + ConnectionDraft draft, + IPAddress address, + HsmsConnectionMode mode) + => new( + address, + draft.Port, + mode, + checked((ushort)draft.SessionId), + TimeSpan.FromSeconds(draft.T3Seconds), + TimeSpan.FromSeconds(draft.T5Seconds), + TimeSpan.FromSeconds(draft.T6Seconds), + TimeSpan.FromSeconds(draft.T7Seconds), + TimeSpan.FromSeconds(draft.T8Seconds)); + + private void HandleLoopbackFailure( + object? sender, + LoopbackPeerFailedEventArgs args) + => AddFailure("回环端停止", args.Error); + + private void AddFailure(string title, Exception error) + { + var diagnostic = HsmsDiagnostic.Classify(error, State); + AddActivity( + ActivityEntryTone.Error, + "诊断", + title, + diagnostic?.Code.ToString() ?? error.GetType().Name, + FormatDiagnostic(diagnostic) ?? error.Message); + } + + private void AddActivity( + ActivityEntryTone tone, + string category, + string title, + string summary, + string? detail) + { + var entry = new ActivityEntry( + Interlocked.Increment(ref _nextActivityId), + DateTimeOffset.Now, + tone, + category, + title, + summary, + detail); + lock (_activityGate) + { + _activities.Insert(0, entry); + if (_activities.Count > ActivityLimit) + { + _activities.RemoveRange( + ActivityLimit, + _activities.Count - ActivityLimit); + } + } + NotifyChanged(); + } + + private void SetBusy(bool value) + { + IsBusy = value; + NotifyChanged(); + } + + private void NotifyChanged() + => Changed?.Invoke(this, EventArgs.Empty); + + private void ThrowIfDisposed() + { + if (Volatile.Read(ref _disposed) != 0) + throw new ObjectDisposedException(nameof(CommunicationWorkspace)); + } + + private static string? FormatDiagnostic(HsmsDiagnostic? diagnostic) + => diagnostic is null + ? null + : $"Code: {diagnostic.Code}\n" + + $"Layer: {diagnostic.Layer}\n" + + $"Operation: {diagnostic.Operation}\n" + + $"State: {diagnostic.State}\n" + + $"Timer: {diagnostic.Timer?.ToString() ?? "-"}\n" + + $"Session: {diagnostic.ProtocolSessionId?.ToString() ?? "-"}\n" + + "System Bytes: " + + (diagnostic.SystemBytes is { } systemBytes + ? $"0x{systemBytes:X8}" + : "-"); + + private static async Task ObserveCompletionAsync(Task? task) + { + if (task is null) + return; + + try + { + await task.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + } +} diff --git a/demo/SecsFrame.CommunicationDemo/Services/DemoLoopbackPeer.cs b/demo/SecsFrame.CommunicationDemo/Services/DemoLoopbackPeer.cs new file mode 100644 index 0000000..b250e09 --- /dev/null +++ b/demo/SecsFrame.CommunicationDemo/Services/DemoLoopbackPeer.cs @@ -0,0 +1,82 @@ +namespace SecsFrame.CommunicationDemo.Services; + +internal sealed class DemoLoopbackPeer : IAsyncDisposable +{ + private readonly CancellationTokenSource _cancellation = new(); + private readonly HsmsConnection _connection; + private readonly Task _eventPump; + + private DemoLoopbackPeer(HsmsConnectionOptions options) + { + _connection = new HsmsConnection(options); + _connection.Start(); + _eventPump = PumpEventsAsync(); + } + + public event EventHandler? Failed; + + public static DemoLoopbackPeer Start(HsmsConnectionOptions options) + { + ArgumentNullException.ThrowIfNull(options); + if (options.ConnectionMode != HsmsConnectionMode.Passive) + { + throw new ArgumentException( + "The demo loopback peer must use Passive mode.", + nameof(options)); + } + + return new DemoLoopbackPeer(options); + } + + public async ValueTask DisposeAsync() + { + _cancellation.Cancel(); + await _connection.DisposeAsync().ConfigureAwait(false); + try + { + await _eventPump.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + _cancellation.Dispose(); + } + + private async Task PumpEventsAsync() + { + try + { + await foreach (var item in _connection + .GetEventsAsync(_cancellation.Token) + .ConfigureAwait(false)) + { + if (item.Kind != HsmsConnectionEventKind.DataMessageReceived) + continue; + + var incoming = item.IncomingMessage!; + if (!incoming.ReplyExpected) + continue; + + var primary = incoming.DataMessage.Message; + var function = primary.Function == byte.MaxValue + ? primary.Function + : (byte)(primary.Function + 1); + await _connection.ReplyAsync( + incoming, + new SecsMessage( + primary.Stream, + function, + rootItem: primary.RootItem), + _cancellation.Token).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + when (_cancellation.IsCancellationRequested) + { + } + catch (Exception error) + { + Failed?.Invoke(this, new LoopbackPeerFailedEventArgs(error)); + } + } +} diff --git a/demo/SecsFrame.CommunicationDemo/Services/LoopbackPeerFailedEventArgs.cs b/demo/SecsFrame.CommunicationDemo/Services/LoopbackPeerFailedEventArgs.cs new file mode 100644 index 0000000..3918642 --- /dev/null +++ b/demo/SecsFrame.CommunicationDemo/Services/LoopbackPeerFailedEventArgs.cs @@ -0,0 +1,11 @@ +namespace SecsFrame.CommunicationDemo.Services; + +internal sealed class LoopbackPeerFailedEventArgs : EventArgs +{ + public LoopbackPeerFailedEventArgs(Exception error) + { + Error = error ?? throw new ArgumentNullException(nameof(error)); + } + + public Exception Error { get; } +} diff --git a/demo/SecsFrame.CommunicationDemo/wwwroot/app.css b/demo/SecsFrame.CommunicationDemo/wwwroot/app.css new file mode 100644 index 0000000..fdfe224 --- /dev/null +++ b/demo/SecsFrame.CommunicationDemo/wwwroot/app.css @@ -0,0 +1,576 @@ +:root { + color-scheme: light; + font-family: Inter, "Segoe UI", "Microsoft YaHei", sans-serif; + font-size: 15px; + letter-spacing: 0; + color: #17201d; + background: #f4f7f6; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-width: 320px; + min-height: 100%; + background: #f4f7f6; +} + +button, +input, +select, +textarea { + font: inherit; + letter-spacing: 0; +} + +button, +select, +input[type="checkbox"] { + cursor: pointer; +} + +button:disabled, +select:disabled, +input:disabled, +textarea:disabled { + cursor: not-allowed; + opacity: 0.58; +} + +.tool-shell { + min-height: 100vh; + background: #f4f7f6; +} + +.tool-header { + min-height: 68px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + padding: 12px 20px; + color: #f7fbf9; + background: #17201d; + border-bottom: 3px solid #2e8b72; +} + +.brand-block { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.brand-mark { + width: 34px; + height: 34px; + display: grid; + place-items: center; + flex: 0 0 auto; + color: #17201d; + background: #73d7b9; + border-radius: 4px; + font-weight: 700; +} + +.brand-block h1 { + margin: 0; + font-size: 1.05rem; + font-weight: 600; +} + +.brand-block p { + margin: 2px 0 0; + color: #aebcb7; + font-size: 0.76rem; +} + +.connection-summary { + display: grid; + grid-template-columns: auto auto; + align-items: center; + column-gap: 8px; + min-width: 0; + text-align: right; +} + +.connection-summary strong { + font-size: 0.86rem; + font-weight: 600; +} + +.connection-summary > span:last-child { + grid-column: 1 / -1; + max-width: 480px; + overflow: hidden; + color: #aebcb7; + font-family: "Cascadia Code", Consolas, monospace; + font-size: 0.72rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.state-dot { + width: 9px; + height: 9px; + border-radius: 50%; + background: #8a9692; +} + +.state-dot.state-selected { + background: #73d7b9; +} + +.state-dot.state-connected, +.state-dot.state-selecting { + background: #f0b85a; +} + +.tool-main { + display: grid; + grid-template-columns: + minmax(270px, 0.72fr) + minmax(390px, 1.08fr) + minmax(330px, 0.9fr); + min-height: calc(100vh - 68px); +} + +.tool-pane { + min-width: 0; + padding: 18px; + background: #ffffff; + border-right: 1px solid #d7dfdc; +} + +.tool-pane:last-child { + border-right: 0; +} + +.tool-pane.is-focus { + box-shadow: inset 0 3px 0 #2e8b72; +} + +.pane-heading { + min-height: 36px; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; +} + +.pane-heading > div { + display: flex; + align-items: baseline; + gap: 8px; +} + +.pane-heading h2 { + margin: 0; + font-size: 0.98rem; + font-weight: 600; +} + +.step-label { + color: #2e8b72; + font-family: "Cascadia Code", Consolas, monospace; + font-size: 0.72rem; + font-weight: 600; +} + +.editor-status { + color: #52615c; + font-size: 0.75rem; +} + +.field-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.field { + display: grid; + gap: 6px; + min-width: 0; + color: #52615c; + font-size: 0.78rem; + font-weight: 500; +} + +.field-wide { + grid-column: 1 / -1; +} + +.field input, +.field select, +.field textarea { + width: 100%; + min-width: 0; + color: #17201d; + background: #fbfdfc; + border: 1px solid #b8c5c0; + border-radius: 4px; + outline: 0; +} + +.field input, +.field select { + min-height: 38px; + padding: 7px 9px; +} + +.field input:focus, +.field select:focus, +.field textarea:focus { + border-color: #26755f; + box-shadow: 0 0 0 3px rgba(46, 139, 114, 0.16); +} + +.check-row { + display: flex; + align-items: center; + gap: 10px; + min-height: 48px; + margin-bottom: 14px; + padding: 8px 10px; + background: #edf6f2; + border-left: 3px solid #2e8b72; +} + +.check-row input { + width: 18px; + height: 18px; + flex: 0 0 auto; +} + +.check-row span { + display: grid; + gap: 2px; +} + +.check-row strong { + font-size: 0.83rem; + font-weight: 600; +} + +.check-row small { + color: #65736e; + font-size: 0.72rem; +} + +.timer-settings { + margin-top: 16px; + padding-top: 12px; + border-top: 1px solid #e1e7e5; +} + +.timer-settings summary { + color: #3f4c48; + cursor: pointer; + font-size: 0.78rem; + font-weight: 600; +} + +.timer-grid { + margin-top: 12px; +} + +.primary-actions { + margin-top: 18px; +} + +.button { + min-height: 38px; + padding: 8px 14px; + border: 1px solid transparent; + border-radius: 4px; + font-weight: 600; +} + +.button-primary { + color: #ffffff; + background: #26755f; + border-color: #26755f; +} + +.button-primary:hover:not(:disabled) { + background: #1f614f; +} + +.button-secondary { + color: #27332f; + background: #ffffff; + border-color: #aebbb6; +} + +.button-secondary:hover:not(:disabled) { + background: #edf2f0; +} + +.button-block { + width: 100%; +} + +.text-button { + min-height: 32px; + padding: 4px 6px; + color: #26755f; + background: transparent; + border: 0; + font-size: 0.76rem; + font-weight: 600; +} + +.template-field { + margin-bottom: 12px; +} + +.message-editor textarea { + min-height: 390px; + resize: vertical; + padding: 14px; + font-family: "Cascadia Code", Consolas, monospace; + font-size: 0.86rem; + line-height: 1.55; + tab-size: 4; +} + +.message-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 14px; +} + +.message-actions .button:first-child { + min-width: 112px; +} + +.operation-error, +.validation-summary, +.validation-message { + color: #a53232; +} + +.operation-error { + margin: 10px 0 0; + padding: 8px 10px; + background: #fff1f0; + border-left: 3px solid #c84c43; + font-size: 0.78rem; +} + +.validation-summary { + margin: 0 0 12px; + padding: 0; + font-size: 0.75rem; +} + +.validation-summary ul { + margin: 0; + padding-left: 18px; +} + +.validation-message { + font-size: 0.72rem; +} + +.activity-pane { + display: flex; + flex-direction: column; + background: #f8faf9; +} + +.activity-feed { + min-height: 0; +} + +.activity-feed ol { + display: grid; + gap: 0; + margin: 0; + padding: 0; + list-style: none; +} + +.activity-item { + position: relative; + padding: 12px 10px 13px 16px; + border-top: 1px solid #dfe6e3; +} + +.activity-item::before { + position: absolute; + top: 17px; + left: 1px; + width: 7px; + height: 7px; + border-radius: 50%; + background: #87938f; + content: ""; +} + +.activity-item.tone-success::before { + background: #2e8b72; +} + +.activity-item.tone-warning::before { + background: #d08b2f; +} + +.activity-item.tone-error::before { + background: #c84c43; +} + +.activity-meta { + display: flex; + justify-content: space-between; + gap: 12px; + margin-bottom: 5px; + color: #6c7974; + font-family: "Cascadia Code", Consolas, monospace; + font-size: 0.68rem; +} + +.activity-item > strong { + display: block; + font-size: 0.82rem; + font-weight: 600; +} + +.activity-item > p { + margin: 4px 0 0; + color: #52615c; + font-size: 0.76rem; + overflow-wrap: anywhere; +} + +.activity-item details { + margin-top: 8px; +} + +.activity-item summary { + color: #26755f; + cursor: pointer; + font-size: 0.72rem; +} + +.activity-item pre { + max-width: 100%; + margin: 8px 0 0; + padding: 10px; + overflow-x: auto; + color: #dce9e4; + background: #202925; + border-radius: 4px; + font-family: "Cascadia Code", Consolas, monospace; + font-size: 0.72rem; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; +} + +.empty-state { + display: grid; + place-items: center; + min-height: 260px; + color: #6c7974; + text-align: center; +} + +.empty-state strong { + align-self: end; + font-size: 0.85rem; +} + +.empty-state span { + align-self: start; + max-width: 220px; + margin-top: 6px; + font-size: 0.75rem; +} + +.not-found { + max-width: 720px; + margin: 80px auto; + padding: 24px; +} + +@media (max-width: 1120px) { + .tool-main { + grid-template-columns: minmax(280px, 0.75fr) minmax(420px, 1.25fr); + } + + .message-pane { + border-right: 0; + } + + .activity-pane { + grid-column: 1 / -1; + border-top: 1px solid #d7dfdc; + } + + .activity-feed ol { + grid-template-columns: repeat(2, minmax(0, 1fr)); + column-gap: 20px; + } +} + +@media (max-width: 760px) { + .tool-header { + align-items: flex-start; + flex-direction: column; + gap: 10px; + padding: 12px 16px; + } + + .connection-summary { + text-align: left; + } + + .connection-summary > span:last-child { + max-width: calc(100vw - 32px); + } + + .tool-main { + display: block; + min-height: 0; + } + + .tool-pane { + padding: 16px; + border-right: 0; + border-bottom: 1px solid #d7dfdc; + } + + .message-editor textarea { + min-height: 320px; + } + + .activity-feed ol { + display: block; + } + + .button { + min-height: 44px; + } +} + +@media (max-width: 420px) { + .field-grid { + grid-template-columns: 1fr; + } + + .field-wide { + grid-column: auto; + } + + .message-actions .button { + flex: 1 1 100%; + } +} diff --git a/demo/SecsFrame.GuidedDemo/Components/App.razor b/demo/SecsFrame.GuidedDemo/Components/App.razor new file mode 100644 index 0000000..e78cd1d --- /dev/null +++ b/demo/SecsFrame.GuidedDemo/Components/App.razor @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/demo/SecsFrame.GuidedDemo/Components/Layout/MainLayout.razor b/demo/SecsFrame.GuidedDemo/Components/Layout/MainLayout.razor new file mode 100644 index 0000000..4f3f76d --- /dev/null +++ b/demo/SecsFrame.GuidedDemo/Components/Layout/MainLayout.razor @@ -0,0 +1,3 @@ +@inherits LayoutComponentBase + +@Body diff --git a/demo/SecsFrame.GuidedDemo/Components/Pages/Home.razor b/demo/SecsFrame.GuidedDemo/Components/Pages/Home.razor new file mode 100644 index 0000000..74f7515 --- /dev/null +++ b/demo/SecsFrame.GuidedDemo/Components/Pages/Home.razor @@ -0,0 +1,243 @@ +@page "/" +@implements IDisposable +@inject GuidedDemoSession Session + +SecsFrame 分步演示 + +
+
+
+ +
+

核心能力演示

+

SecsFrame Guided Demo

+
+
+
+ + @Session.State +
+
+ +
+
+
+ +
+ + +
+
+ @if (!Session.IsStarted) + { + 准备 + } + else if (Session.IsComplete) + { + 完成 + } + else + { + 步骤 @((Session.CurrentStepIndex + 1).ToString("00")) + } +
+ +
+

@StageTitle

+

@StageSummary

+
+ + @if (Session.IsBusy) + { +
+ + 正在执行真实操作 +
+ } + else if (!string.IsNullOrEmpty(Session.Error)) + { + + } + else if (Session.CurrentResult is { } current) + { +
+ @foreach (var evidence in current.Evidence) + { +
+
@evidence.Label
+
@evidence.Value
+
+ } +
+ + @if (!string.IsNullOrEmpty(current.Code)) + { +
+
+ SML 证据 + 确定性调试文本 +
+
@current.Code
+
+ } + } + else + { +
+ 5 +
+ 固定步骤 + 真实 TCP、消息、事务、控制与恢复 +
+
+ } + +
+ @if (!Session.IsStarted) + { + + } + else if (Session.IsComplete) + { + + } + else + { + + } +
+
+ + +
+
+ +@code { + private string StageTitle + { + get + { + if (!Session.IsStarted) + return "从连接到恢复"; + if (Session.IsComplete) + return "演示完成"; + if (Session.IsBusy) + return Session.Steps[Session.CurrentStepIndex].Name; + if (Session.CurrentResult is { } result) + return result.Title; + return Session.Steps[Session.CurrentStepIndex].Name; + } + } + + private string StageSummary + { + get + { + if (!Session.IsStarted) + return "五个动作依次展示当前核心 HSMS 与动态消息成果。"; + if (Session.IsComplete) + return "五个真实动作均已完成,所有证据保留在本次会话中。"; + if (Session.IsBusy) + return Session.Steps[Session.CurrentStepIndex].Focus; + if (Session.CurrentResult is { } result) + return result.Summary; + return Session.Steps[Session.CurrentStepIndex].Focus; + } + } + + private string NextActionLabel + { + get + { + if (!string.IsNullOrEmpty(Session.Error)) + return "重试本步"; + var next = Session.Results.Count; + return next < Session.Steps.Count + ? $"下一步:{Session.Steps[next].Name}" + : "完成"; + } + } + + protected override void OnInitialized() + => Session.Changed += HandleSessionChanged; + + public void Dispose() + => Session.Changed -= HandleSessionChanged; + + private string StepState(int index) + { + if (index < Session.Results.Count) + return "is-complete"; + if (Session.IsStarted && index == Session.CurrentStepIndex) + return "is-active"; + return "is-pending"; + } + + private void HandleSessionChanged(object? sender, EventArgs args) + => _ = InvokeAsync(StateHasChanged); +} diff --git a/demo/SecsFrame.GuidedDemo/Components/Routes.razor b/demo/SecsFrame.GuidedDemo/Components/Routes.razor new file mode 100644 index 0000000..85c9889 --- /dev/null +++ b/demo/SecsFrame.GuidedDemo/Components/Routes.razor @@ -0,0 +1,14 @@ + + + + + + + +
+

页面不存在

+
返回演示 +
+ + + diff --git a/demo/SecsFrame.GuidedDemo/Components/_Imports.razor b/demo/SecsFrame.GuidedDemo/Components/_Imports.razor new file mode 100644 index 0000000..edafd17 --- /dev/null +++ b/demo/SecsFrame.GuidedDemo/Components/_Imports.razor @@ -0,0 +1,9 @@ +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using SecsFrame +@using SecsFrame.GuidedDemo +@using SecsFrame.GuidedDemo.Components +@using SecsFrame.GuidedDemo.Components.Layout +@using SecsFrame.GuidedDemo.Models +@using SecsFrame.GuidedDemo.Services diff --git a/demo/SecsFrame.GuidedDemo/Models/DemoEvidence.cs b/demo/SecsFrame.GuidedDemo/Models/DemoEvidence.cs new file mode 100644 index 0000000..8289eef --- /dev/null +++ b/demo/SecsFrame.GuidedDemo/Models/DemoEvidence.cs @@ -0,0 +1,5 @@ +namespace SecsFrame.GuidedDemo.Models; + +internal sealed record DemoEvidence( + string Label, + string Value); diff --git a/demo/SecsFrame.GuidedDemo/Models/GuidedDemoStep.cs b/demo/SecsFrame.GuidedDemo/Models/GuidedDemoStep.cs new file mode 100644 index 0000000..c797fbb --- /dev/null +++ b/demo/SecsFrame.GuidedDemo/Models/GuidedDemoStep.cs @@ -0,0 +1,32 @@ +namespace SecsFrame.GuidedDemo.Models; + +internal sealed record GuidedDemoStep( + int Number, + string Name, + string Focus) +{ + public static IReadOnlyList All { get; } = + new[] + { + new GuidedDemoStep( + 1, + "建立会话", + "真实 TCP 与 Selected"), + new GuidedDemoStep( + 2, + "构造消息", + "动态 Item 与 SML"), + new GuidedDemoStep( + 3, + "完成事务", + "Primary 与 Secondary"), + new GuidedDemoStep( + 4, + "检查链路", + "Linktest 控制事务"), + new GuidedDemoStep( + 5, + "恢复会话", + "Separate 与新代次"), + }; +} diff --git a/demo/SecsFrame.GuidedDemo/Models/GuidedStepResult.cs b/demo/SecsFrame.GuidedDemo/Models/GuidedStepResult.cs new file mode 100644 index 0000000..c914f35 --- /dev/null +++ b/demo/SecsFrame.GuidedDemo/Models/GuidedStepResult.cs @@ -0,0 +1,8 @@ +namespace SecsFrame.GuidedDemo.Models; + +internal sealed record GuidedStepResult( + int StepNumber, + string Title, + string Summary, + IReadOnlyList Evidence, + string? Code); diff --git a/demo/SecsFrame.GuidedDemo/Program.cs b/demo/SecsFrame.GuidedDemo/Program.cs new file mode 100644 index 0000000..2e49369 --- /dev/null +++ b/demo/SecsFrame.GuidedDemo/Program.cs @@ -0,0 +1,26 @@ +using SecsFrame.GuidedDemo.Components; +using SecsFrame.GuidedDemo.Services; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services + .AddRazorComponents() + .AddInteractiveServerComponents(); +builder.Services.AddScoped(); + +var app = builder.Build(); + +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/error"); + app.UseHsts(); +} + +app.UseStaticFiles(); +app.UseAntiforgery(); + +app.MapGet("/favicon.ico", static () => Results.NoContent()); +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +app.Run(); diff --git a/demo/SecsFrame.GuidedDemo/Properties/launchSettings.json b/demo/SecsFrame.GuidedDemo/Properties/launchSettings.json new file mode 100644 index 0000000..f682991 --- /dev/null +++ b/demo/SecsFrame.GuidedDemo/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5081", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/demo/SecsFrame.GuidedDemo/SecsFrame.GuidedDemo.csproj b/demo/SecsFrame.GuidedDemo/SecsFrame.GuidedDemo.csproj new file mode 100644 index 0000000..22f6b3b --- /dev/null +++ b/demo/SecsFrame.GuidedDemo/SecsFrame.GuidedDemo.csproj @@ -0,0 +1,13 @@ + + + net8.0 + false + + + + + + + + + diff --git a/demo/SecsFrame.GuidedDemo/Services/GuidedDemoSession.cs b/demo/SecsFrame.GuidedDemo/Services/GuidedDemoSession.cs new file mode 100644 index 0000000..8cc23b7 --- /dev/null +++ b/demo/SecsFrame.GuidedDemo/Services/GuidedDemoSession.cs @@ -0,0 +1,492 @@ +using System.Globalization; +using System.Net; +using System.Net.Sockets; +using SecsFrame.GuidedDemo.Models; +using SecsFrame.Sml; + +namespace SecsFrame.GuidedDemo.Services; + +internal sealed class GuidedDemoSession : IAsyncDisposable +{ + private const ushort ProtocolSessionId = 10; + private readonly SemaphoreSlim _operationGate = new(1, 1); + private readonly object _selectionGate = new(); + private readonly List _results = new(); + private readonly SmlMessageCodec _sml = new(); + private TaskCompletionSource _nextActiveSelection = + CreateSelectionSignal(); + private CancellationTokenSource? _sessionCancellation; + private HsmsConnection? _active; + private HsmsConnection? _passive; + private Task? _activePump; + private Task? _passivePump; + private SecsMessage? _sampleMessage; + private long _activeSelectionGeneration; + private int _disposed; + + public event EventHandler? Changed; + + public IReadOnlyList Steps => GuidedDemoStep.All; + + public IReadOnlyList Results => _results.ToArray(); + + public GuidedStepResult? CurrentResult => + _results.Count == 0 ? null : _results[^1]; + + public int CurrentStepIndex { get; private set; } = -1; + + public bool IsStarted => CurrentStepIndex >= 0; + + public bool IsBusy { get; private set; } + + public bool IsComplete { get; private set; } + + public string? Error { get; private set; } + + public HsmsSessionState State { get; private set; } = + HsmsSessionState.Disconnected; + + public int ProgressPercent => + _results.Count * 100 / GuidedDemoStep.All.Count; + + public Task StartAsync() => RunStepAsync(reset: true); + + public Task NextAsync() => RunStepAsync(reset: false); + + public Task RestartAsync() => RunStepAsync(reset: true); + + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + return; + + await _operationGate.WaitAsync().ConfigureAwait(false); + try + { + await DisposePairAsync().ConfigureAwait(false); + } + finally + { + _operationGate.Release(); + _operationGate.Dispose(); + } + } + + private async Task RunStepAsync(bool reset) + { + ThrowIfDisposed(); + await _operationGate.WaitAsync().ConfigureAwait(false); + try + { + SetBusy(true); + if (reset) + await ResetAsync().ConfigureAwait(false); + if (IsComplete) + return; + + await ExecuteNextStepAsync().ConfigureAwait(false); + } + catch (Exception error) + { + Error = error.Message; + NotifyChanged(); + } + finally + { + SetBusy(false); + _operationGate.Release(); + } + } + + private async Task ResetAsync() + { + await DisposePairAsync().ConfigureAwait(false); + _results.Clear(); + _sampleMessage = null; + CurrentStepIndex = -1; + IsComplete = false; + Error = null; + State = HsmsSessionState.Disconnected; + lock (_selectionGate) + { + _activeSelectionGeneration = 0; + _nextActiveSelection = CreateSelectionSignal(); + } + NotifyChanged(); + } + + private async Task ExecuteNextStepAsync() + { + var index = _results.Count; + if (index >= GuidedDemoStep.All.Count) + { + IsComplete = true; + return; + } + + CurrentStepIndex = index; + Error = null; + NotifyChanged(); + var result = index switch + { + 0 => await EstablishSessionAsync().ConfigureAwait(false), + 1 => BuildMessage(), + 2 => await CompleteTransactionAsync().ConfigureAwait(false), + 3 => await RunLinktestAsync().ConfigureAwait(false), + 4 => await RecoverSessionAsync().ConfigureAwait(false), + _ => throw new InvalidOperationException( + "The guided demo has no action for the current step."), + }; + _results.Add(result); + IsComplete = _results.Count == GuidedDemoStep.All.Count; + NotifyChanged(); + } + + private async Task EstablishSessionAsync() + { + var port = GetFreePort(); + var cancellation = new CancellationTokenSource(); + _sessionCancellation = cancellation; + _passive = new HsmsConnection( + CreateOptions(port, HsmsConnectionMode.Passive)); + _active = new HsmsConnection( + CreateOptions(port, HsmsConnectionMode.Active)); + _passive.Start(); + _passivePump = PumpPassiveEventsAsync( + _passive, + cancellation.Token); + _active.Start(); + _activePump = PumpActiveEventsAsync( + _active, + cancellation.Token); + + using var timeout = CancellationTokenSource + .CreateLinkedTokenSource(cancellation.Token); + timeout.CancelAfter(TimeSpan.FromSeconds(12)); + await Task.WhenAll( + _passive.WaitUntilSelectedAsync(timeout.Token), + WaitForActiveSelectionAfterAsync(0, timeout.Token)) + .ConfigureAwait(false); + + return new GuidedStepResult( + 1, + "真实会话已建立", + "Active 与 Passive 通过本机 TCP 完成 Select。", + new[] + { + new DemoEvidence("端点", $"127.0.0.1:{port}"), + new DemoEvidence("拓扑", "Active -> Passive"), + new DemoEvidence("Session ID", ProtocolSessionId.ToString()), + new DemoEvidence("状态", State.ToString()), + }, + null); + } + + private GuidedStepResult BuildMessage() + { + _sampleMessage = new SecsMessage( + 6, + 11, + replyExpected: true, + rootItem: SecsItem.List( + SecsItem.Ascii("DEMO-LOT-01"), + SecsItem.U4(1001), + SecsItem.List( + SecsItem.Boolean(true), + SecsItem.F8(23.5)))); + var text = _sml.Encode(_sampleMessage); + return new GuidedStepResult( + 2, + "动态消息已构造", + "不可变 Item 树被写成确定性 SML 调试文本。", + new[] + { + new DemoEvidence("消息", "S6F11 W"), + new DemoEvidence("根 Item", "List [3]"), + new DemoEvidence("嵌套深度", "3"), + new DemoEvidence("文本长度", $"{text.Length} 字符"), + }, + text); + } + + private async Task CompleteTransactionAsync() + { + var active = RequireActive(); + var primary = _sampleMessage ?? + throw new InvalidOperationException("演示消息尚未构造。"); + var secondary = await active.SendAsync(primary) + .ConfigureAwait(false) ?? + throw new InvalidOperationException("演示事务没有返回 Secondary。"); + if (!Equals(primary.RootItem, secondary.Message.RootItem)) + { + throw new InvalidOperationException( + "演示回环返回的 Item Body 与 Primary 不一致。"); + } + + return new GuidedStepResult( + 3, + "数据事务已完成", + "W-Bit Primary 与匹配 Secondary 使用同一 System Bytes。", + new[] + { + new DemoEvidence( + "回复", + $"S{secondary.Message.Stream}F{secondary.Message.Function}"), + new DemoEvidence( + "System Bytes", + $"0x{secondary.SystemBytes:X8}"), + new DemoEvidence("Body", "结构相等"), + new DemoEvidence("会话", active.State.ToString()), + }, + _sml.Encode(secondary.Message)); + } + + private async Task RunLinktestAsync() + { + var active = RequireActive(); + await active.LinktestAsync().ConfigureAwait(false); + return new GuidedStepResult( + 4, + "链路检查已完成", + "Linktest Request 与匹配响应在 Selected 会话中完成。", + new[] + { + new DemoEvidence("命令", "Linktest"), + new DemoEvidence("结果", "响应已匹配"), + new DemoEvidence("状态", active.State.ToString()), + new DemoEvidence("数据 Body", "无"), + }, + null); + } + + private async Task RecoverSessionAsync() + { + var active = RequireActive(); + long previousGeneration; + lock (_selectionGate) + previousGeneration = _activeSelectionGeneration; + + await active.SeparateAsync().ConfigureAwait(false); + using var timeout = CancellationTokenSource + .CreateLinkedTokenSource( + _sessionCancellation?.Token ?? CancellationToken.None); + timeout.CancelAfter(TimeSpan.FromSeconds(12)); + var recoveredGeneration = await WaitForActiveSelectionAfterAsync( + previousGeneration, + timeout.Token).ConfigureAwait(false); + return new GuidedStepResult( + 5, + "替换会话已恢复", + "Separate 关闭旧会话,Active 重连后进入新的 Selected 代次。", + new[] + { + new DemoEvidence( + "旧代次", + previousGeneration.ToString( + CultureInfo.InvariantCulture)), + new DemoEvidence( + "新代次", + recoveredGeneration.ToString( + CultureInfo.InvariantCulture)), + new DemoEvidence("状态", State.ToString()), + new DemoEvidence("后台重放", "无"), + }, + null); + } + + private async Task PumpActiveEventsAsync( + HsmsConnection connection, + CancellationToken cancellationToken) + { + try + { + await foreach (var item in connection + .GetEventsAsync(cancellationToken) + .ConfigureAwait(false)) + { + if (item.Kind != HsmsConnectionEventKind.StateChanged) + continue; + + State = item.State; + if (item.State == HsmsSessionState.Selected) + SignalActiveSelection(); + NotifyChanged(); + } + } + catch (OperationCanceledException) + when (cancellationToken.IsCancellationRequested) + { + } + catch (Exception error) + { + Error = error.Message; + NotifyChanged(); + } + } + + private async Task PumpPassiveEventsAsync( + HsmsConnection connection, + CancellationToken cancellationToken) + { + try + { + await foreach (var item in connection + .GetEventsAsync(cancellationToken) + .ConfigureAwait(false)) + { + if (item.Kind != HsmsConnectionEventKind.DataMessageReceived) + continue; + + await ReplyToPrimaryAsync( + connection, + item.IncomingMessage!, + cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + when (cancellationToken.IsCancellationRequested) + { + } + catch (Exception error) + { + Error = error.Message; + NotifyChanged(); + } + } + + private static Task ReplyToPrimaryAsync( + HsmsConnection connection, + HsmsIncomingDataMessage incoming, + CancellationToken cancellationToken) + { + if (!incoming.ReplyExpected) + return Task.CompletedTask; + + var primary = incoming.DataMessage.Message; + var function = primary.Function == byte.MaxValue + ? primary.Function + : (byte)(primary.Function + 1); + return connection.ReplyAsync( + incoming, + new SecsMessage( + primary.Stream, + function, + rootItem: primary.RootItem), + cancellationToken); + } + + private async Task WaitForActiveSelectionAfterAsync( + long previousGeneration, + CancellationToken cancellationToken) + { + while (true) + { + Task signal; + lock (_selectionGate) + { + if (_activeSelectionGeneration > previousGeneration) + return _activeSelectionGeneration; + signal = _nextActiveSelection.Task; + } + + await signal.WaitAsync(cancellationToken).ConfigureAwait(false); + } + } + + private void SignalActiveSelection() + { + TaskCompletionSource signal; + long generation; + lock (_selectionGate) + { + generation = ++_activeSelectionGeneration; + signal = _nextActiveSelection; + _nextActiveSelection = CreateSelectionSignal(); + } + signal.TrySetResult(generation); + } + + private async Task DisposePairAsync() + { + var active = _active; + var passive = _passive; + var cancellation = _sessionCancellation; + var activePump = _activePump; + var passivePump = _passivePump; + _active = null; + _passive = null; + _sessionCancellation = null; + _activePump = null; + _passivePump = null; + + cancellation?.Cancel(); + if (active is not null) + await active.DisposeAsync().ConfigureAwait(false); + if (passive is not null) + await passive.DisposeAsync().ConfigureAwait(false); + await ObserveCompletionAsync(activePump).ConfigureAwait(false); + await ObserveCompletionAsync(passivePump).ConfigureAwait(false); + cancellation?.Dispose(); + State = HsmsSessionState.Disconnected; + } + + private HsmsConnection RequireActive() + => _active ?? + throw new InvalidOperationException( + "演示连接尚未建立。"); + + private static HsmsConnectionOptions CreateOptions( + int port, + HsmsConnectionMode mode) + => new( + IPAddress.Loopback, + port, + mode, + ProtocolSessionId, + t3: TimeSpan.FromSeconds(3), + t5: TimeSpan.FromMilliseconds(50), + t6: TimeSpan.FromSeconds(3), + t7: TimeSpan.FromSeconds(5), + t8: TimeSpan.FromSeconds(3)); + + private static int GetFreePort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + private void SetBusy(bool value) + { + IsBusy = value; + NotifyChanged(); + } + + private void NotifyChanged() + => Changed?.Invoke(this, EventArgs.Empty); + + private void ThrowIfDisposed() + { + if (Volatile.Read(ref _disposed) != 0) + throw new ObjectDisposedException(nameof(GuidedDemoSession)); + } + + private static TaskCompletionSource CreateSelectionSignal() + => new(TaskCreationOptions.RunContinuationsAsynchronously); + + private static async Task ObserveCompletionAsync(Task? task) + { + if (task is null) + return; + + try + { + await task.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + } +} diff --git a/demo/SecsFrame.GuidedDemo/wwwroot/app.css b/demo/SecsFrame.GuidedDemo/wwwroot/app.css new file mode 100644 index 0000000..9f7cec2 --- /dev/null +++ b/demo/SecsFrame.GuidedDemo/wwwroot/app.css @@ -0,0 +1,652 @@ +:root { + color-scheme: light; + font-family: Inter, "Segoe UI", "Microsoft YaHei", sans-serif; + font-size: 15px; + letter-spacing: 0; + color: #17201d; + background: #f3f6f5; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-width: 320px; + min-height: 100%; + background: #f3f6f5; +} + +button { + font: inherit; + letter-spacing: 0; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.58; +} + +.tour-shell { + min-height: 100vh; +} + +.tour-header { + min-height: 68px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 12px 22px; + color: #f7fbf9; + background: #17201d; +} + +.brand-block { + display: flex; + align-items: center; + gap: 12px; +} + +.brand-mark { + width: 34px; + height: 34px; + display: grid; + place-items: center; + flex: 0 0 auto; + color: #17201d; + background: #73d7b9; + border-radius: 4px; + font-weight: 700; +} + +.brand-block h1 { + margin: 0; + font-size: 1.05rem; + font-weight: 600; +} + +.brand-block p { + margin: 2px 0 0; + color: #aebcb7; + font-size: 0.75rem; +} + +.session-state { + display: flex; + align-items: center; + gap: 8px; + color: #c8d3cf; + font-size: 0.78rem; +} + +.state-dot { + width: 9px; + height: 9px; + border-radius: 50%; + background: #8a9692; +} + +.state-dot.state-selected { + background: #73d7b9; +} + +.state-dot.state-connected, +.state-dot.state-selecting { + background: #f0b85a; +} + +.tour-progress { + height: 4px; + overflow: hidden; + background: #dce4e1; +} + +.tour-progress-bar { + height: 100%; + background: #d87931; + transition: width 240ms ease; +} + +@media (prefers-reduced-motion: reduce) { + .tour-progress-bar { + transition: none; + } +} + +.tour-layout { + display: grid; + grid-template-columns: + minmax(230px, 0.62fr) + minmax(480px, 1.35fr) + minmax(300px, 0.83fr); + min-height: calc(100vh - 72px); +} + +.step-rail, +.stage, +.evidence-rail { + min-width: 0; + padding: 22px; +} + +.step-rail { + background: #eef3f1; + border-right: 1px solid #d3dcd8; +} + +.stage { + display: flex; + flex-direction: column; + background: #ffffff; + border-right: 1px solid #d3dcd8; +} + +.evidence-rail { + display: flex; + flex-direction: column; + background: #f8f6f2; +} + +.rail-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 32px; + margin-bottom: 18px; + color: #5c6864; + font-size: 0.74rem; + font-weight: 600; +} + +.rail-heading strong { + color: #17201d; + font-family: "Cascadia Code", Consolas, monospace; + font-weight: 600; +} + +.step-rail ol { + margin: 0; + padding: 0; + list-style: none; +} + +.step-item { + position: relative; + display: grid; + grid-template-columns: 34px 1fr; + gap: 10px; + min-height: 66px; + padding: 10px 4px; +} + +.step-item:not(:last-child)::after { + position: absolute; + top: 44px; + bottom: -10px; + left: 16px; + width: 1px; + background: #c5cfcb; + content: ""; +} + +.step-number { + width: 32px; + height: 32px; + display: grid; + place-items: center; + z-index: 1; + color: #6a7772; + background: #eef3f1; + border: 1px solid #aebbb6; + border-radius: 50%; + font-family: "Cascadia Code", Consolas, monospace; + font-size: 0.7rem; +} + +.step-item > div { + display: grid; + align-content: start; + gap: 3px; + padding-top: 2px; +} + +.step-item strong { + font-size: 0.82rem; + font-weight: 600; +} + +.step-item > div > span { + color: #6a7772; + font-size: 0.72rem; +} + +.step-item.is-active .step-number { + color: #ffffff; + background: #26755f; + border-color: #26755f; +} + +.step-item.is-active strong { + color: #1f614f; +} + +.step-item.is-complete .step-number { + color: #713b18; + background: #f5d6bd; + border-color: #d87931; +} + +.stage-kicker { + min-height: 28px; + color: #d06c26; + font-family: "Cascadia Code", Consolas, monospace; + font-size: 0.75rem; + font-weight: 600; +} + +.stage-heading { + max-width: 720px; +} + +.stage-heading h2 { + margin: 0; + font-size: 1.55rem; + font-weight: 600; +} + +.stage-heading p { + max-width: 650px; + margin: 8px 0 0; + color: #58645f; + font-size: 0.88rem; + line-height: 1.55; +} + +.ready-surface, +.running-state, +.error-state { + min-height: 230px; + display: flex; + align-items: center; + justify-content: center; + gap: 18px; + margin-top: 28px; + border-top: 1px solid #e1e6e4; + border-bottom: 1px solid #e1e6e4; +} + +.ready-index { + color: #d87931; + font-family: "Cascadia Code", Consolas, monospace; + font-size: 4.5rem; + font-weight: 600; +} + +.ready-surface > div { + display: grid; + gap: 6px; +} + +.ready-surface strong { + font-size: 1rem; + font-weight: 600; +} + +.ready-surface div span { + color: #66736e; + font-size: 0.78rem; +} + +.running-state { + flex-direction: column; +} + +.running-mark { + width: 44px; + height: 44px; + border: 4px solid #cbd6d2; + border-top-color: #26755f; + border-radius: 50%; + animation: spin 900ms linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .running-mark { + animation: none; + border-color: #26755f; + } +} + +.running-state strong, +.error-state strong { + font-size: 0.85rem; + font-weight: 600; +} + +.error-state { + flex-direction: column; + color: #9d302f; + background: #fff6f4; +} + +.error-state span { + max-width: 520px; + font-size: 0.78rem; + text-align: center; +} + +.evidence-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0; + margin: 28px 0 0; + border-top: 1px solid #dfe6e3; + border-left: 1px solid #dfe6e3; +} + +.evidence-grid > div { + min-height: 86px; + padding: 14px; + border-right: 1px solid #dfe6e3; + border-bottom: 1px solid #dfe6e3; +} + +.evidence-grid dt { + color: #66736e; + font-size: 0.72rem; + font-weight: 500; +} + +.evidence-grid dd { + margin: 9px 0 0; + color: #17201d; + font-family: "Cascadia Code", Consolas, monospace; + font-size: 0.88rem; + overflow-wrap: anywhere; +} + +.code-evidence { + margin-top: 20px; +} + +.code-heading { + display: flex; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; + color: #66736e; + font-size: 0.72rem; +} + +.code-heading span:first-child { + color: #17201d; + font-weight: 600; +} + +.code-evidence pre { + max-height: 310px; + margin: 0; + padding: 16px; + overflow: auto; + color: #dce9e4; + background: #202925; + border-radius: 4px; + font-family: "Cascadia Code", Consolas, monospace; + font-size: 0.78rem; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +} + +.stage-actions { + display: flex; + align-items: flex-end; + flex: 1 0 auto; + min-height: 90px; + padding-top: 24px; +} + +.button { + min-width: 150px; + min-height: 42px; + padding: 9px 16px; + cursor: pointer; + border: 1px solid transparent; + border-radius: 4px; + font-weight: 600; +} + +.button-primary { + color: #ffffff; + background: #26755f; + border-color: #26755f; +} + +.button-primary:hover:not(:disabled) { + background: #1f614f; +} + +.button-secondary { + color: #27332f; + background: #ffffff; + border-color: #aebbb6; +} + +.button-secondary:hover:not(:disabled) { + background: #edf2f0; +} + +.history-empty { + min-height: 220px; + display: grid; + place-items: center; + align-content: center; + gap: 5px; + color: #6c7773; + text-align: center; +} + +.history-empty strong { + color: #35413d; + font-size: 0.82rem; + font-weight: 600; +} + +.history-empty span { + font-size: 0.72rem; +} + +.result-history { + margin: 0; + padding: 0; + list-style: none; +} + +.result-history li { + display: grid; + grid-template-columns: 28px 1fr; + gap: 10px; + padding: 12px 0; + border-top: 1px solid #dddcd6; +} + +.result-history li > span { + color: #d06c26; + font-family: "Cascadia Code", Consolas, monospace; + font-size: 0.7rem; + font-weight: 600; +} + +.result-history strong { + display: block; + font-size: 0.78rem; + font-weight: 600; +} + +.result-history p { + margin: 4px 0 0; + color: #68736f; + font-size: 0.71rem; + line-height: 1.45; +} + +.boundary-note { + display: grid; + gap: 5px; + margin-top: auto; + padding-top: 16px; + border-top: 1px solid #d8d7d1; +} + +.boundary-note strong { + font-size: 0.74rem; + font-weight: 600; +} + +.boundary-note span { + color: #6f7774; + font-size: 0.7rem; + line-height: 1.45; +} + +.not-found { + max-width: 720px; + margin: 80px auto; + padding: 24px; +} + +@media (max-width: 1080px) { + .tour-layout { + grid-template-columns: 230px minmax(0, 1fr); + } + + .stage { + border-right: 0; + } + + .evidence-rail { + grid-column: 1 / -1; + min-height: 240px; + border-top: 1px solid #d3dcd8; + } + + .result-history { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + column-gap: 24px; + } +} + +@media (max-width: 720px) { + .tour-header { + min-height: 64px; + padding: 10px 16px; + } + + .brand-block p { + display: none; + } + + .tour-layout { + display: block; + min-height: 0; + } + + .step-rail, + .stage, + .evidence-rail { + padding: 16px; + } + + .step-rail { + border-right: 0; + border-bottom: 1px solid #d3dcd8; + } + + .step-rail ol { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + } + + .step-item { + display: grid; + grid-template-columns: 1fr; + justify-items: center; + min-height: 58px; + padding: 4px 2px; + text-align: center; + } + + .step-item:not(:last-child)::after { + top: 16px; + right: -50%; + bottom: auto; + left: 50%; + width: 100%; + height: 1px; + } + + .step-item > div { + display: block; + } + + .step-item > div > span { + display: none; + } + + .step-item strong { + display: block; + margin-top: 4px; + font-size: 0.68rem; + } + + .stage { + min-height: 520px; + border-bottom: 1px solid #d3dcd8; + } + + .stage-heading h2 { + font-size: 1.3rem; + } + + .ready-surface, + .running-state, + .error-state { + min-height: 190px; + } + + .result-history { + display: block; + } + + .button { + width: 100%; + min-height: 44px; + } +} + +@media (max-width: 420px) { + .tour-header { + align-items: flex-start; + flex-direction: column; + gap: 8px; + } + + .evidence-grid { + grid-template-columns: 1fr; + } + + .ready-index { + font-size: 3.4rem; + } +} diff --git a/docs/DEMOS.md b/docs/DEMOS.md new file mode 100644 index 0000000..d13ea67 --- /dev/null +++ b/docs/DEMOS.md @@ -0,0 +1,110 @@ +# Demo 体验与迭代接续 + +## 目标与边界 + +仓库提供两个面向不同意图的本机 Web Demo: + +- SecsFrame.CommunicationDemo 面向集成和现场调试。用户配置 + 端点、建立连接、选择或编辑 SML、发送数据/控制消息,并在同一工作面查看 + 状态、消息与结构化诊断。 +- SecsFrame.GuidedDemo 面向成果演示。用户从一个明确的开始 + 动作进入固定脚本,每一步只聚焦一个能力、一个实际动作和一组可见证据。 + +两个 Demo 都直接引用仓库项目,不复制协议实现。它们展示已实现的工程行为, +不新增标准默认值、设备消息 Profile 或 SEMI 合规结论。本机回环响应规则只 +用于形成可观察事务,不代表任何设备接口定义。 + +采用 ASP.NET Core Interactive Server 是为了让浏览器承担界面,同时让本机 +服务进程直接使用 HsmsConnection 的 TCP 能力。首轮不引入新的 +前端框架或外部运行期资源。 + +## 注意力模型 + +### 通讯测试工具 + +| 阶段 | 用户首要问题 | 主关注区 | 持续证据 | +|---|---|---|---| +| 未连接 | 参数是否正确、能否连上 | 连接配置 | 顶栏状态与连接活动 | +| Selected | 发什么、是否等待回复 | SML 编辑与发送 | Session、System Bytes | +| 操作中 | 动作是否完成 | 当前命令与禁用状态 | 活动时间线 | +| 故障 | 哪一层、哪个操作、哪个计时器 | 最新诊断 | 稳定诊断代码和详细字段 | +| 复盘 | 刚才发生了什么 | 活动与诊断 | 有界、倒序的会话内记录 | + +因此界面不是传统仪表盘:连接、消息、日志是一个从左到右的工作流。未连接时 +强调第一栏;进入 Selected 后强调第二栏;第三栏始终可见且不抢占当前操作。 +窄屏按相同顺序纵向排列,避免改变工作心智。 + +### 分步演示系统 + +| 阶段 | 用户首要问题 | 主关注区 | 持续证据 | +|---|---|---|---| +| 开始前 | 这次会看到什么 | 场景标题与开始动作 | 固定步骤总数 | +| 执行中 | 当前系统做了什么 | 当前步骤舞台 | 已完成步骤轨迹 | +| 结果出现 | 如何证明不是静态动画 | 实际结果 | 状态、SML、事务标识 | +| 步骤切换 | 下一步关注什么 | 下一步动作 | 已完成证据保留 | +| 完成 | 当前成果覆盖到哪里 | 运行总结 | 能力/边界清单 | + +演示系统不暴露连接表单或自由编辑器。所有输入固定且可复现;每次只执行当前 +步骤,用户明确点击后才前进,不使用自动轮播。 + +## 当前工程 + +### 通讯工具 + +运行: + +~~~bash +dotnet run --project demo/SecsFrame.CommunicationDemo/SecsFrame.CommunicationDemo.csproj +~~~ + +默认地址为 。首个纵向切片包括: + +- Active/Passive、IP、端口、Session ID 和显式 T3/T5/T6/T7/T8; +- 默认开启的本机真实 TCP Active/Passive 回环; +- 三个可编辑 SML 样例与严格 SML 解析; +- 数据消息发送、Secondary 显示、Linktest 和 Separate; +- 最多 500 条的内存活动流,显示状态、方向、System Bytes、SML 和受限 + 结构化诊断;不自动写盘或上传。 + +当前回环端对 W-Bit 消息返回相同 Body,并把 Function 加一。这个规则在界面 +日志中明确标为非设备 Profile。 + +### 分步演示 + +运行: + +~~~bash +dotnet run --project demo/SecsFrame.GuidedDemo/SecsFrame.GuidedDemo.csproj +~~~ + +默认地址为 。首个纵向切片固定演示公共连接、Selected、 +动态 Item/SML、真实 Primary/Secondary、Linktest 和会话恢复。GEM、Trace、 +诊断故障样本与运输故障将在后续脚本中逐项加入。 + +## 迭代账本 + +每个开发会话最多完成两轮迭代。完成一轮后立即更新本节;达到两轮后停止新增 +范围,把下一轮留给新会话。 + +| 轮次 | 状态 | 范围 | 验收 | +|---|---|---|---| +| D1 | 已完成 | 通讯工具首条连接/消息/日志纵向链路 | Release 编译;真实回环发送与控制命令;1440px/390px 无横向溢出 | +| D2 | 已完成 | 分步演示首条核心 HSMS 导览 | 五步实际动作与证据;Release 编译;1440px/390px 无横向溢出 | +| D3 | 下一会话 | 通讯工具入站回复、消息收藏、日志筛选/脱敏导出 | 先定义数据分级;外部端点与回环回归 | +| D4 | 下一会话 | 分步演示 GEM 基础与 Trace/诊断场景 | 每个场景引用现有公共 API 和专题边界 | +| D5 | 待排期 | 启动器、发布打包、可访问性与用户试用修正 | 可重复安装/启动;键盘与窄屏完整走通 | + +## 跨会话接续 + +新会话按以下顺序恢复: + +1. 阅读根目录 AGENTS.md、README.md、 + docs/DEVELOPMENT-STATUS.md 和本文。 +2. 检查本文迭代账本、最新 Git 历史、工作区与对应 Demo 项目。 +3. 从最新 main 创建符合仓库约定的主题分支。 +4. 一次只把一个“动作到证据”的纵向切片标为进行中;不得同时开启第三轮。 +5. 行为、边界、运行命令或下一优先级变化时,同步本文、CHANGELOG 和开发 + 状态;完整 Release 构建测试仍是提交门禁。 + +所有演示文本应区分“实际执行证据”和“说明性边界”。未经授权标准与一致性 +测试核对,不得把工程回环、固定脚本或成功运行改写成标准合规或生产就绪声明。 diff --git a/docs/DEVELOPMENT-STATUS.md b/docs/DEVELOPMENT-STATUS.md index 975d062..5dcfe48 100644 --- a/docs/DEVELOPMENT-STATUS.md +++ b/docs/DEVELOPMENT-STATUS.md @@ -20,8 +20,8 @@ GitHub Issue/Actions: ## 当前基线 -截至 2026-08-30,本切片基于 `main` 的 PR #42(提交 `64713b7`),升级后 -基线为: +截至 2026-08-31,本切片在 StreamFrame 2.6.0 与独立 session soak 基线上 +继续开发,当前基线为: - StreamFrame 固定为官方 NuGet `2.6.0`。 - secs4net 互操作测试固定为 nuget.org 官方 `3.1.0`,不得改用源码引用或 @@ -39,6 +39,10 @@ GitHub Issue/Actions: - 独立 SecsFrame.Soak 和 session-soak 工作流提供 显式 seed、四类真实 TCP 故障、程序/周期/作业三层上限及 14 天 JSONL 产物;普通 CI 只编译 harness,不运行长时负载。 +- 本机 SecsFrame.CommunicationDemo 已形成连接、SML 发送、 + Linktest/Separate 和会话内诊断活动流的首条纵向链路; + SecsFrame.GuidedDemo 已形成五步真实核心链路演示。两个项目 + 的注意力模型、运行边界和跨会话账本见 [Demo 接续](DEMOS.md)。 - 最近一次完整本地验证中,核心测试在 `net48`、`net8.0`、`net10.0` 各 通过 289 项;官方 secs4net 互操作测试在 `net8.0`、`net10.0` 各通过 2 项。 @@ -94,6 +98,11 @@ Primary/Secondary、断开和重选,并验证: half-close、reset 或跨平台矩阵。扩大故障范围前必须保持固定运行上限和可 重现输入,也不能用 soak 替代确定性回归和完整普通 CI。 +Demo 工作按 [Demo 体验与迭代接续](DEMOS.md) 独立分轮:当前 D1、D2 已完成, +下一开发会话最多推进 D3、D4。D3 先为通讯工具增加入站回复、消息收藏和日志 +筛选/脱敏导出;任何落盘或分享能力必须先明确数据分级。D4 再把既有 GEM +基础与 Trace/诊断能力加入固定演示脚本,不新增未经标准核对的消息语义。 + ## 状态更新规则 - 每个改变路线图优先级或外部阻塞的 PR 都应同步更新本文。