스풀링
스풀링(spooling)은 호스트와 통신이 끊긴 동안 장비가 보낼 메시지를 보관하는 GEM 기능입니다. 통신이 다시 수립되면 호스트가 보관된 메시지를 보내라고(transmit) 하거나 버리라고(purge) 요청합니다. 이벤트 보고(S6F11)나 알람 보고(S5F1)처럼 놓치면 안 되는 데이터를 지키는 데 씁니다.
이 튜토리얼에서는 PDComm 예제로 스풀링 선택, 전송, 삭제, 전달 증거(delivery proof), 저장 파일을 모두 실행합니다.
- S2F43으로 스풀링할 스트림과 펑션을 고르고, RSPACK·STRACK 응답을 확인합니다.
- S6F23으로 보관된 메시지를 보내게 하거나 버리게 하고, RSDA 응답을 확인합니다.
- W 비트가 있는 메시지와 없는 메시지의 전달 증거가 어떻게 다른지 이해합니다.
- 스풀을 파일에 저장하면 프로그램을 다시 시작해도 보관 메시지가 남는다는 점을 확인합니다.
준비물
섹션 제목: “준비물”- 개발 환경 준비에서 정한 변수
PDCOMM_ROOT,PDCOMM_SDK,PDCOMM_NATIVE,PDCOMM_EXAMPLES(PowerShell은$Root,$Sdk,$Native,$Examples) - .NET 10 SDK
- 이벤트 보고와 알람 보고의 기본 흐름: 보고서와 이벤트, 알람
스풀링 메시지
섹션 제목: “스풀링 메시지”| 메시지 | 방향 | 내용 | 응답 코드 |
|---|---|---|---|
| S2F43/S2F44 Reset Spooling Streams and Functions | 호스트 → 장비 | 스풀링할 스트림과 펑션을 고릅니다. 펑션 목록이 비면 그 스트림 전체, 스트림 목록이 비면 스풀링하지 않음입니다. | RSPACK, 거부한 스트림마다 STRACK |
| S6F23/S6F24 Request Spooled Data | 호스트 → 장비 | RSDC 0은 전송, 1은 삭제입니다. | RSDA |
| S2F59/S2F60 | 호스트 → 장비 | PDComm 장비의 현재 스풀링 선택을 조회합니다. | - |
| 코드 | 값과 의미 |
|---|---|
| RSPACK | 0 수락, 1 거부. 거부하면 선택은 바뀌지 않습니다. |
| STRACK | 1 그 스트림은 스풀링할 수 없음, 2 모르는 스트림, 3 그 스트림의 모르는 펑션, 4 응답(secondary) 펑션을 지정함 |
| RSDA | 0 수락, 1 전송이나 전달이 진행 중이라 거부, 2 보관된 메시지 없음 |
PDComm 장비의 스풀링 모델
섹션 제목: “PDComm 장비의 스풀링 모델”PDComm은 스풀링의 각 단계를 애플리케이션이 직접 부르게 합니다. 아무것도 자동으로 보내지 않습니다.
ConfigureSpooling(catalog, file) spoolable messages, optional snapshot file ConfigureLocalSpooling(policy) Enabled, MaxTransmit, overflow, selection | v SyncSpool(now) -- communication down --> spooling ACTIVE | CaptureSpool / CaptureSpoolEvent / CaptureSpoolAlarm --> entry stored | host S6F23 Transmit --> RespondSpool --> transmission run starts | SendNextSpool --> delivery --> proof: Response (W-bit) or Written (no W-bit) | CompleteSpoolDelivery(result, success) --> entry removed- 카탈로그(catalog): 스풀링할 수 있는 메시지 목록입니다.
GemEquipment.ConfigureSpooling로 시작 전에 한 번 정합니다. 스트림 1은 스풀링할 수 없습니다. - 선택(selection): 실제로 스풀링할 스트림과 펑션입니다. 호스트가 S2F43으로 바꿀 수 있지만 카탈로그 안에서만 고를 수 있습니다.
- 정책(policy): 스풀링 허용 여부, 한 번 전송할 때의 최대 개수(
MaxTransmit, 0은 제한 없음), 가득 찼을 때 동작입니다. - 동기화: 이벤트를 처리한 뒤
GemEquipment.SyncSpool를 부릅니다. 통신이 끊겨 있고 정책이 허용하면 스풀링이 활성화됩니다.now는 애플리케이션이 정하는 시각 값이며 PDComm이 해석하지 않습니다. - 보관: 스풀링이 활성이고 선택된 메시지일 때
GemEquipment.CaptureSpool가 항목을 저장합니다. 이벤트와 알람은CaptureSpoolEvent,CaptureSpoolAlarm으로 보관합니다. - 전달: S6F23 전송 요청을
GemEquipment.RespondSpool로 수락하면 전송 회차(transmission run)가 시작됩니다. 항목마다GemEquipment.SendNextSpool를 한 번씩 부릅니다. - 전달 증거: W 비트가 있는 항목은 호스트의 응답(
Response)이, 없는 항목은 프레임 전체를 썼다는 사실(Written)이 증거입니다.Written은 로컬 쓰기 완료일 뿐 호스트가 받았다는 증거가 아닙니다(GemSpoolDeliveryProof). - 제거: 응답 본문을 확인한 뒤
GemEquipment.CompleteSpoolDelivery로 성공을 알리면 항목이 지워집니다. 같은 증거로 두 번 완료하면StaleToken으로 거부됩니다.
1. 예제 빌드
섹션 제목: “1. 예제 빌드”dotnet build "$Examples\dotnet\PDComm.GemSpoolingDemo\PDComm.GemSpoolingDemo.csproj" -c Release "-p:PDCommSdk=$Sdk" --artifacts-path "$Root\build\managed"dotnet build "$PDCOMM_EXAMPLES/dotnet/PDComm.GemSpoolingDemo/PDComm.GemSpoolingDemo.csproj" -c Release "-p:PDCommSdk=$PDCOMM_SDK" --artifacts-path "$PDCOMM_ROOT/build/managed"dotnet build "$PDCOMM_EXAMPLES/dotnet/PDComm.GemSpoolingDemo/PDComm.GemSpoolingDemo.csproj" -c Release "-p:PDCommSdk=$PDCOMM_SDK" --artifacts-path "$PDCOMM_ROOT/build/managed"기대 결과: Build succeeded.와 0 Error(s)가 출력됩니다.
2. 예제 실행
섹션 제목: “2. 예제 실행”이 예제는 다섯 번째 인수로 스풀 저장 파일 경로를 받습니다. 장비는 새 파일 경로를, 호스트는 -를 넘깁니다. 호스트는 자체 스풀을 갖지 않습니다.
# 터미널 A: 장비dotnet "$Root\build\managed\bin\PDComm.GemSpoolingDemo\release\PDComm.GemSpoolingDemo.dll" $Native equipment passive 127.0.0.1:0 "$Root\equipment.spool"# 터미널 B: 호스트dotnet "$Root\build\managed\bin\PDComm.GemSpoolingDemo\release\PDComm.GemSpoolingDemo.dll" $Native host active 127.0.0.1:<port> -# 터미널 A: 장비dotnet "$PDCOMM_ROOT/build/managed/bin/PDComm.GemSpoolingDemo/release/PDComm.GemSpoolingDemo.dll" "$PDCOMM_NATIVE" equipment passive 127.0.0.1:0 "$PDCOMM_ROOT/equipment.spool"# 터미널 B: 호스트dotnet "$PDCOMM_ROOT/build/managed/bin/PDComm.GemSpoolingDemo/release/PDComm.GemSpoolingDemo.dll" "$PDCOMM_NATIVE" host active 127.0.0.1:<port> -# 터미널 A: 장비dotnet "$PDCOMM_ROOT/build/managed/bin/PDComm.GemSpoolingDemo/release/PDComm.GemSpoolingDemo.dll" "$PDCOMM_NATIVE" equipment passive 127.0.0.1:0 "$PDCOMM_ROOT/equipment.spool"# 터미널 B: 호스트dotnet "$PDCOMM_ROOT/build/managed/bin/PDComm.GemSpoolingDemo/release/PDComm.GemSpoolingDemo.dll" "$PDCOMM_NATIVE" host active 127.0.0.1:<port> -기대 결과: 양쪽이 다음 줄을 출력하고 종료 코드 0으로 끝납니다. 장비 쪽 폴더에는 equipment.spool과 잠금 파일 equipment.spool.lock이 생깁니다.
PASS host active DOTNET SPOOLING PEERLISTEN 127.0.0.1:54615PASS equipment passive DOTNET SPOOLING PEER3. 시나리오 따라 읽기
섹션 제목: “3. 시나리오 따라 읽기”장비는 시작하기 전에(Demo.ConfigureEquipment) 스풀을 구성합니다.
- 카탈로그: S3F1, S3F3, S5F1, S6F11
- 정책: 스풀링 허용, 한 번에 최대 2개 전송(
MaxTransmit = 2), 가득 차면 새 메시지를 버림(DiscardNew) - 선택: 스트림 3의 펑션 1, 3
- 아직 통신 전이므로
SyncSpool(100)이 스풀링을 활성화하고, S3F1(W 비트, 본문"BOOT-A")과 S3F3(W 비트 없음, 본문 없음)을 항목 1, 2로 보관합니다.
통신이 수립되면 호스트가 18단계를 진행합니다(Demo.SendHostStep). 장비는 받은 스풀링 요청마다 RespondSpool로 답합니다.
| 단계 | 호스트 요청 | 결과 |
|---|---|---|
| 0 | S2F59 현재 선택 조회 | 스트림 3, 펑션 1·3 |
| 1 | S2F43: S1F1, S4F1, S3F2 | RSPACK 1, STRACK 1(스트림 1 불가), 2(모르는 스트림 4), 4(응답 펑션 2). 선택은 그대로입니다. |
| 2 | S2F43: 스트림 3 전체, S5F1, S6F11 | RSPACK 0 |
| 3–6 | 보고서 정의, 이벤트 연결, 이벤트 활성화, 알람 7 활성화 | 모두 0 |
| 7 | S1F3 상태 변수 11 | 값 10. 이 응답 뒤 장비가 이벤트(CEID 10)와 알람 7을 항목 3, 4로 보관합니다. |
| 8 | S6F23 전송 | RSDA 0. 장비가 항목 1(S3F1)을 보내고 호스트가 S3F2로 답합니다. 항목 2(S3F3)는 Written 증거로 완료됩니다. MaxTransmit = 2라서 전송 회차가 끝납니다. |
| 9 | S1F3 상태 변수 11 | 값 99. 장비 스풀에 2개가 남았는지 확인합니다. |
| 10 | S6F23 전송 | RSDA 0. 항목 3(S6F11)과 4(S5F1)가 전달되고 호스트가 ACKC6 0, ACKC5 0으로 답합니다. |
| 11 | S1F3 | 스풀이 비었는지 확인합니다. |
| 12, 13 | S6F23 전송, 삭제 | 둘 다 RSDA 2(보관된 메시지 없음) |
| 14, 15 | 빈 S2F43, S2F59 | 선택이 비었음을 확인합니다. |
| 16, 17 | S2F43 다시 설정, S1F3 | 선택을 되돌리고 끝냅니다. |
장비는 전달마다 응답 본문을 확인하고 CompleteSpoolDelivery(result, true)로 항목을 지웁니다. 같은 결과로 한 번 더 완료하면 StaleToken이 나는 것도 확인합니다.
호스트가 연결을 끊으면 장비는 통신 중단을 감지하고(Demo.CaptureFinal) S3F1, S3F3을 항목 5, 6으로 다시 보관합니다. 이 두 항목은 저장 파일에 남습니다.
PDComm.GemSpoolingDemo/Program.cs 전체 코드
using PDComm;
if (args.Length < 5 || !Path.IsPathFullyQualified(args[0]) || args[1] is not ("host" or "equipment") || args[2] is not ("active" or "passive")){ Console.Error.WriteLine("Usage: PDComm.GemSpoolingDemo <absolute-native-library-path> <host|equipment> <active|passive> <numeric-address> <snapshot-path>"); return 2;}
PdcommRuntime.Initialize(args[0]);if (args.Length == 6){ if (args[5] != "simulator") throw new ArgumentException("The optional profile must be simulator."); return SimulatorProfile.Run(args[1], args[2], args[3], args[4]);}bool isHost = args[1] == "host";HsmsMode mode = args[2] == "active" ? HsmsMode.Active : HsmsMode.Passive;using GemEndpoint endpoint = isHost ? GemHost.Create(args[3], Demo.Transport(mode), Demo.Host) : GemEquipment.Create(args[3], Demo.Transport(mode), Demo.Equipment);
if (endpoint is GemHost host) host.ConfigureSpoolHost(Demo.HostSpool);else Demo.ConfigureEquipment((GemEquipment)endpoint, args[4] == "-" ? null : args[4]);
endpoint.Start();if (mode == HsmsMode.Passive){ Console.WriteLine($"LISTEN {endpoint.GetLocalAddress()}"); Console.Out.Flush();}
int step = 0, served = 0, deliveries = 0, barriers = 0;int stopDelay = -1;bool began = false, communicating = false, pendingActive = false, stopping = false;HsmsToken pending = default;DateTime deadline = DateTime.UtcNow.AddSeconds(45);ulong clock = 300;
while (DateTime.UtcNow < deadline){ endpoint.Poll(); HsmsEvent? transportEvent; while ((transportEvent = endpoint.NextEvent()) is not null) { using (transportEvent) using (GemResult result = endpoint.HandleEvent(transportEvent)) { HsmsEventInfo wire = transportEvent.GetInfo(); GemResultInfo info = result.GetInfo(); if (!isHost) ((GemEquipment)endpoint).SyncSpool(clock++);
if (isHost && Demo.IsGenericPrimary(wire)) { Demo.VerifyGenericPrimary(transportEvent, deliveries); if (deliveries == 0) Demo.ReplyGeneric(endpoint, transportEvent); deliveries++; } else if (isHost && info.Kind == GemResultKind.Primary && info.Outcome is GemOutcome.EventReceived or GemOutcome.AlarmReceived) { Demo.VerifyTypedPrimary((GemHost)endpoint, result, deliveries); deliveries++; } else if (!isHost && info.Kind == GemResultKind.Primary && info.Outcome == GemOutcome.SpoolReceived) { Demo.VerifyEquipmentRequest(result, served); PdcommStatus status = ((GemEquipment)endpoint).RespondSpool(result); if (status != PdcommStatus.Ok) throw new InvalidOperationException($"Unexpected spool response status {status}."); served++; } else if (!isHost && info.Kind == GemResultKind.Response && info.RequestKind == GemRequestKind.SpoolDelivery) { Demo.VerifyDelivery(result, deliveries); GemSpoolWriteResult completed = ((GemEquipment)endpoint).CompleteSpoolDelivery(result, true); if (!completed.Applied || completed.Mutation != GemSpoolMutation.Removed || completed.AffectedCount != 1) throw new InvalidOperationException("The spooled delivery was not removed."); try { ((GemEquipment)endpoint).CompleteSpoolDelivery(result, true); throw new InvalidOperationException("A consumed delivery proof was accepted."); } catch (PdcommException error) when (error.Status == PdcommStatus.StaleToken) { } deliveries++; } else if (isHost && info.Kind == GemResultKind.Response) { if (!pendingActive || info.Token != pending) throw new InvalidOperationException("The spool response token did not match."); pendingActive = false; if (info.RequestKind == GemRequestKind.Establish) { if (info.Outcome != GemOutcome.CommunicationEstablished) throw new InvalidOperationException("GEM communication was rejected."); communicating = true; } else { Demo.VerifyHostResponse(result, step); step++; } } else if (!isHost && info.Outcome == GemOutcome.StatusReplied) { barriers++; if (barriers == 1) Demo.CaptureTyped((GemEquipment)endpoint); else if (barriers is 2 or 3) Demo.VerifyEquipmentState((GemEquipment)endpoint, barriers == 2 ? 2u : 0u); else if (barriers != 4) throw new InvalidOperationException("The host sent an extra completion barrier."); } else if (info.Kind is GemResultKind.Failed or GemResultKind.InvalidResponse || (info.Kind == GemResultKind.Unhandled && !Demo.IsGenericPrimary(wire))) { throw new InvalidOperationException( $"Spooling protocol failed: kind={info.Kind}, outcome={info.Outcome}, request={info.RequestKind}."); } } }
GemState gem = endpoint.GetState(); if (gem.Communication == GemCommunicationState.Communicating) communicating = true; HsmsState transport = endpoint.GetTransportState(); if (isHost && !began && transport.State == HsmsConnectionState.Selected) { pending = endpoint.BeginCommunication(); began = true; pendingActive = true; } if (isHost && communicating && step < Demo.HostSteps && Demo.MinimumDeliveries(step) <= deliveries && !pendingActive) { pending = Demo.SendHostStep((GemHost)endpoint, step); pendingActive = true; } if (!isHost && communicating) { GemEquipment equipment = (GemEquipment)endpoint; GemSpoolState state = equipment.GetSpoolState(); if (state.Transmitting && !state.DeliveryPending) equipment.SendNextSpool(); } if (isHost && step == Demo.HostSteps && deliveries == Demo.DeliveryCount && stopDelay < 0) stopDelay = 32; if (isHost && stopDelay == 0 && !stopping) { endpoint.Stop(); stopping = true; } else if (isHost && stopDelay > 0) stopDelay--; if (!isHost && communicating && transport.State != HsmsConnectionState.Selected && !stopping) { if (served != Demo.RequestCount || deliveries != Demo.DeliveryCount || barriers != 4) throw new InvalidOperationException( $"The host disconnected early: requests={served}, deliveries={deliveries}, barriers={barriers}."); Demo.CaptureFinal((GemEquipment)endpoint); endpoint.Stop(); stopping = true; } if (stopping && endpoint.GetTransportState().Closed) { if (isHost && (step != Demo.HostSteps || deliveries != Demo.DeliveryCount)) throw new InvalidOperationException("The host stopped before the spool exchange completed."); Console.WriteLine($"PASS {args[1]} {args[2]} DOTNET SPOOLING PEER"); return 0; } Thread.Sleep(1);}
throw new TimeoutException( $"Spooling peer timed out: role={args[1]}, step={step}, served={served}, deliveries={deliveries}, barriers={barriers}.");
internal static class Demo{ internal const int HostSteps = 18; internal const int DeliveryCount = 4; internal const int RequestCount = 10; internal static readonly SecsLimits Limits = new(4096, 4096, 256, 16); internal static readonly GemSpoolOptions Spool = new() { MaxMessages = 8, MaxBodyBytes = 1024, MaxTotalBodyBytes = 4096, MaxStreams = 4, MaxFunctionsPerStream = 8, MaxCatalogEntries = 8, MaxSerializedBytes = 8192 }; internal static readonly GemSpoolHostOptions HostSpool = new() { MaxStreams = 4, MaxFunctionsPerStream = 8, MaxPendingItems = 16 }; internal static readonly GemHostOptions Host = new() { MaxPendingIds = 64, ItemLimits = Limits }; internal static readonly GemEquipmentOptions Equipment = new() { Model = "PDComm", SoftwareRevision = "0.1.0", InitialControl = GemControlState.HostOffline, OnlineMode = GemOnlineMode.Remote, MaxVariables = 8, MaxMetadataBytes = 1024, MaxValueBytes = 4096, ItemLimits = Limits }; private static readonly GemSpoolMessageKey[] Catalog = [new(3, 1), new(3, 3), new(5, 1), new(6, 11)];
internal static HsmsOptions Transport(HsmsMode mode) => new() { Mode = mode, SessionId = 7, InitialSystemBytes = 1, T3Milliseconds = 2000, T5Milliseconds = 20, T6Milliseconds = 200, T7Milliseconds = 1000, T8Milliseconds = 500, ConnectTimeoutMilliseconds = 50, ShutdownTimeoutMilliseconds = 100, WriteTimeoutMilliseconds = 1000, MaxFrameBytes = 4110, MaxEvents = 128, MaxQueuedBytes = 65536, MaxWriteFrames = 64, MaxWriteBytes = 65536, MaxIoChunk = 3, MaxApplicationEvents = 128, MaxApplicationBytes = 65536, PollBudget = 32, ItemLimits = Limits };
internal static void ConfigureEquipment(GemEquipment equipment, string? path) { using SecsItem occurrence = SecsItem.FromU4([10], Limits); equipment.RegisterStatus(11, "Occurrence", "count", occurrence); equipment.ConfigureReports(new GemReportOptions { MaxEvents = 4, MaxReports = 4, MaxVariablesPerReport = 8, MaxReportsPerEvent = 4, MaxTotalVariables = 16, MaxTotalLinks = 16 }, [10]); equipment.ConfigureAlarms(new GemAlarmOptions { MaxAlarms = 4, MaxTotalTextBytes = 128, MaxTextBytes = 120 }, [new GemAlarmDefinition(7, 2, "Pressure")]); equipment.ConfigureSpooling(Spool, Catalog, path); equipment.ConfigureLocalSpooling( new GemSpoolPolicy { Overflow = GemSpoolOverflow.DiscardNew, MaxTransmit = 2, Enabled = true }, InitialSelections()); equipment.SyncSpool(100); using SecsItem body = SecsItem.FromAscii("BOOT-A", Limits); RequireCapture(equipment.CaptureSpool(3, 1, true, body, 101), 1); RequireCapture(equipment.CaptureSpool(3, 3, false, null, 102), 2); }
internal static bool IsGenericPrimary(HsmsEventInfo info) => info.Kind == HsmsEventKind.Received && !info.IsReply && info.Header is { Stream: 3, Function: 1 or 3 };
internal static void VerifyGenericPrimary(HsmsEvent value, int ordinal) { if (ordinal is < 0 or > 1) throw new InvalidOperationException("The equipment sent an extra generic spool delivery."); HsmsEventInfo info = value.GetInfo(); HsmsHeader header = info.Header ?? throw new InvalidOperationException("The generic delivery had no header."); byte function = ordinal == 0 ? (byte)1 : (byte)3; if (header.Function != function || header.ReplyExpected != (ordinal == 0)) throw new InvalidOperationException("The generic spool delivery header differed."); using SecsItem? body = value.DecodeBody(Limits); if (ordinal == 0 && body?.GetAscii() != "BOOT-A") throw new InvalidOperationException("The generic spool body differed."); if (ordinal == 1 && body is not null) throw new InvalidOperationException("The bodyless spool delivery gained a body."); }
internal static void ReplyGeneric(GemEndpoint endpoint, HsmsEvent primary) { using SecsItem acknowledgement = SecsItem.FromU1([0], Limits); using SecsItem text = SecsItem.FromAscii("ACCEPT", Limits); using SecsItem body = SecsItem.CreateList([acknowledgement, text], Limits); endpoint.ReplyRaw(primary, 3, 2, false, body.Encode()); }
internal static void VerifyTypedPrimary(GemHost host, GemResult result, int ordinal) { if (ordinal == 2) { GemEventInfo info = result.GetEventInfo(); GemEventReport report = result.GetEventReport(0); using SecsItem value = result.GetEventValue(0, 0); if (info.DataId != 1000 || info.EventId != 10 || info.ReportCount != 1 || report.ReportId != 100 || report.ValueCount != 1 || !value.GetU4().SequenceEqual(new uint[] { 10 })) throw new InvalidOperationException("The spooled event report differed."); host.AcknowledgeEvent(result, 0); } else if (ordinal == 3) { GemAlarmResultInfo info = result.GetAlarmInfo(); GemAlarmValue alarm = result.GetAlarmAt(0); if (!info.IsReport || !info.ReplyExpected || info.Count != 1 || alarm.Info.Id != 7 || alarm.Info.Category != 2 || !alarm.Info.Active || alarm.Text != "Pressure") throw new InvalidOperationException("The spooled alarm report differed."); host.AcknowledgeAlarm(result, 0); } else throw new InvalidOperationException("The typed delivery order differed."); }
internal static void VerifyEquipmentRequest(GemResult result, int ordinal) { int[] steps = [0, 1, 2, 8, 10, 12, 13, 14, 15, 16]; if ((uint)ordinal >= steps.Length) throw new InvalidOperationException("The host sent an extra spool request."); int step = steps[ordinal]; GemResultInfo generic = result.GetInfo(); GemSpoolInfo info = result.GetSpoolInfo(); if (!info.IsRequest || info.Responded || generic.RequestKind != RequestKind(step)) throw new InvalidOperationException("The incoming spool request differed."); if (generic.RequestKind == GemRequestKind.SpoolConfigure && info.SelectionCount != (nuint)SelectionsForStep(step).Length) throw new InvalidOperationException("The incoming spool selection count differed."); if (generic.RequestKind == GemRequestKind.SpoolControl && info.Command != (step == 13 ? GemSpoolCommand.Purge : GemSpoolCommand.Transmit)) throw new InvalidOperationException("The incoming spool command differed."); }
internal static void VerifyDelivery(GemResult result, int ordinal) { if ((uint)ordinal >= DeliveryCount) throw new InvalidOperationException("The host acknowledged an extra spool delivery."); GemResultInfo generic = result.GetInfo(); GemSpoolInfo info = result.GetSpoolInfo(); GemOutcome expected = ordinal == 1 ? GemOutcome.SpoolDeliveryWritten : GemOutcome.SpoolDeliveryResponse; GemSpoolDeliveryProof proof = ordinal == 1 ? GemSpoolDeliveryProof.Written : GemSpoolDeliveryProof.Response; if (generic.Outcome != expected || info.DeliveryProof != proof || info.EntryId != (ulong)ordinal + 1 || info.DeliveryAttempt != (ulong)ordinal + 1 || info.DeliveryCompleted) throw new InvalidOperationException("The spool delivery proof differed."); if (ordinal == 0) { using SecsItem? body = result.GetBody(); using SecsItem acknowledgement = SecsItem.FromU1([0], Limits); using SecsItem text = SecsItem.FromAscii("ACCEPT", Limits); using SecsItem expectedBody = SecsItem.CreateList([acknowledgement, text], Limits); if (body is null || !body.Encode().SequenceEqual(expectedBody.Encode())) throw new InvalidOperationException("The generic spool response body differed."); } else if (ordinal >= 2) { using SecsItem? body = result.GetBody(); if (body is null || !body.GetBinary().SequenceEqual(new byte[] { 0 })) throw new InvalidOperationException("The typed spool acknowledgement differed."); } }
internal static int MinimumDeliveries(int step) => step is 9 or 10 ? 2 : step >= 11 ? 4 : 0;
internal static HsmsToken SendHostStep(GemHost host, int step) => step switch { 0 or 15 => host.QueryRemoteSpooling(), 1 or 2 or 14 or 16 => host.RequestSpoolConfiguration(SelectionsForStep(step)), 3 => host.DefineReports(1, [new GemReportSpec(100, new uint[] { 11 })]), 4 => host.LinkReports(2, [new GemLinkSpec(10, new uint[] { 100 })]), 5 => host.EnableEvents(true, [10]), 6 => host.EnableAlarm(7, true, true), 7 or 9 or 11 or 17 => host.RequestStatusValues([11]), 8 or 10 or 12 => host.RequestSpooledData(GemSpoolCommand.Transmit), 13 => host.RequestSpooledData(GemSpoolCommand.Purge), _ => throw new ArgumentOutOfRangeException(nameof(step)), };
internal static void VerifyHostResponse(GemResult result, int step) { GemResultInfo generic = result.GetInfo(); if (generic.RequestKind != RequestKind(step)) throw new InvalidOperationException("The spool response kind differed."); if (step is 3 or 4 or 5 or 6) { if (generic.Acknowledgement != 0) throw new InvalidOperationException("A configuration request was rejected."); return; } if (step is 7 or 9 or 11 or 17) { using GemStatusValue status = result.GetStatusValue(0); uint expected = step == 7 ? 10u : 99u; if (status.Id != 11 || !status.Value.GetU4().SequenceEqual(new uint[] { expected })) throw new InvalidOperationException("The barrier status value differed."); return; } GemSpoolInfo info = result.GetSpoolInfo(); if (step is 0 or 15) { GemSpoolSelection[] expected = step == 0 ? InitialSelections() : []; if (generic.Outcome != GemOutcome.SpoolConfigurationData || info.SelectionCount != (nuint)expected.Length) throw new InvalidOperationException("The spool query result differed."); VerifyResultSelections(result, expected); } else if (step == 1) { byte[] streams = [1, 4, 3]; byte[] acknowledgements = [1, 2, 4]; if (generic.Outcome != GemOutcome.SpoolConfigurationAck || info.Acknowledgement != 1 || info.ErrorCount != 3) throw new InvalidOperationException("The invalid spool selection was not rejected."); for (nuint index = 0; index < info.ErrorCount; index++) { using GemSpoolResultError error = result.GetSpoolError(index); if (error.Stream != streams[index] || error.Acknowledgement != acknowledgements[index]) throw new InvalidOperationException("The spool selection error differed."); } } else if (step is 2 or 14 or 16) { if (generic.Outcome != GemOutcome.SpoolConfigurationAck || info.Acknowledgement != 0 || info.ErrorCount != 0 || info.Applied) throw new InvalidOperationException("The valid spool selection acknowledgement differed."); } else { byte expected = step is 12 or 13 ? (byte)2 : (byte)0; if (generic.Outcome != GemOutcome.SpoolControlAck || info.Acknowledgement != expected) throw new InvalidOperationException("The spool control acknowledgement differed."); } }
internal static void CaptureTyped(GemEquipment equipment) { RequireCapture(equipment.CaptureSpoolEvent(1000, 10, [], 200), 3); GemAlarmChange set = equipment.ObserveAlarm(7, true); if (!set.Changed || !set.ReportingEnabled) throw new InvalidOperationException("The alarm did not become reportable."); RequireCapture(equipment.CaptureSpoolAlarm(7, true, 201), 4); using SecsItem value = SecsItem.FromU4([99], Limits); equipment.UpdateStatus(11, value); equipment.ObserveAlarm(7, false); }
internal static void VerifyEquipmentState(GemEquipment equipment, nuint expectedCount) { GemSpoolState state = equipment.GetSpoolState(); if (state.MessageCount != expectedCount || !state.Synchronized || state.DeliveryPending || state.DequeuePending) throw new InvalidOperationException("The equipment spool state differed."); }
internal static void CaptureFinal(GemEquipment equipment) { equipment.SyncSpool(900); using SecsItem empty = SecsItem.FromBinary([], Limits); RequireCapture(equipment.CaptureSpool(3, 1, true, empty, 901), 5); RequireCapture(equipment.CaptureSpool(3, 3, false, null, 902), 6); }
private static void RequireCapture(GemSpoolWriteResult result, ulong entryId) { if (!result.Applied || !result.Selected || result.EntryId != entryId) throw new InvalidOperationException($"Spool entry {entryId} was not captured."); }
private static GemRequestKind RequestKind(int step) => step switch { 0 or 15 => GemRequestKind.SpoolQuery, 1 or 2 or 14 or 16 => GemRequestKind.SpoolConfigure, 8 or 10 or 12 or 13 => GemRequestKind.SpoolControl, 3 => GemRequestKind.DefineReports, 4 => GemRequestKind.LinkReports, 5 => GemRequestKind.EnableEvents, 6 => GemRequestKind.EnableAlarm, _ => GemRequestKind.StatusValues, };
private static GemSpoolSelection[] InitialSelections() => [new GemSpoolSelection(3, new byte[] { 1, 3 })];
private static GemSpoolSelection[] ValidSelections() => [new GemSpoolSelection(3, Array.Empty<byte>()), new GemSpoolSelection(5, new byte[] { 1 }), new GemSpoolSelection(6, new byte[] { 11 })];
private static GemSpoolSelection[] SelectionsForStep(int step) => step switch { 1 => [new GemSpoolSelection(1, new byte[] { 1 }), new GemSpoolSelection(4, new byte[] { 1 }), new GemSpoolSelection(3, new byte[] { 2 })], 14 => [], 2 or 16 => ValidSelections(), _ => throw new ArgumentOutOfRangeException(nameof(step)), };
private static void VerifyResultSelections(GemResult result, GemSpoolSelection[] expected) { for (nuint index = 0; index < (nuint)expected.Length; index++) { using GemSpoolResultSelection actual = result.GetSpoolSelection(index); GemSpoolSelection expectedSelection = expected[checked((int)index)]; if (actual.Stream != expectedSelection.Stream || !actual.Functions.GetU1().SequenceEqual(expectedSelection.Functions.ToArray())) throw new InvalidOperationException("The returned spool selection differed."); } }}4. 저장 파일 확인
섹션 제목: “4. 저장 파일 확인”같은 파일로 장비를 다시 실행해 봅니다.
dotnet "$PDCOMM_ROOT/build/managed/bin/PDComm.GemSpoolingDemo/release/PDComm.GemSpoolingDemo.dll" "$PDCOMM_NATIVE" equipment passive 127.0.0.1:0 "$PDCOMM_ROOT/equipment.spool"기대 결과: 장비가 바로 다음 예외로 끝납니다.
Unhandled exception. System.InvalidOperationException: Spool entry 1 was not captured.오류가 아니라 저장이 동작한다는 증거입니다. 파일에는 지난 실행의 마지막 항목 5, 6과 카운터가 남아 있으므로, 새로 보관한 항목의 번호가 1부터 시작하지 않습니다. 예제는 빈 스풀을 전제로 항목 번호를 검사합니다. 예제를 다시 실행하려면 새 파일 경로를 쓰거나 equipment.spool과 equipment.spool.lock을 지웁니다.
저장 파일이 있는 스풀은 다음 규칙을 따릅니다.
- 끝점이 살아 있는 동안 파일 옆의 잠금 파일을 독점합니다. 다른 프로세스가 같은 파일을 쓰면
Backpressure로 거부됩니다. - 바뀔 때마다 임시 파일에 전체 상태를 쓰고, 파일 이름을 바꿔 덮어쓰고, 폴더를 동기화합니다.
- 저장된 카탈로그와 다른 카탈로그로 열면
InvalidData로 거부합니다. 파일은 조용히 바뀌거나 이전되지 않습니다. - 저장을 확인하지 못하면 결과가
PdcommStatus.SpoolUnconfirmed입니다.RespondSpool이 이 값을 돌려주면 응답은 보내지 않고 보류합니다.GemEquipment.ConfirmSpoolReply를 부른 뒤 같은 결과로RespondSpool을 다시 호출합니다.
Qt 예제 묶음의 spooling.cpp도 이 처리 순서(PDCOMM_SPOOL_UNCONFIRMED → confirmSpoolReply → respondSpool)를 Qt 작업 스레드에서 따릅니다. 이 예제는 PDComm 내부 시험과 시나리오 정의를 공유하므로 SDK만으로는 빌드되지 않고, 이 문서에도 싣지 않습니다. Qt 스레드 구조는 원격 명령의 commands.cpp에서 확인합니다.
5. 시뮬레이터 GUI에서 확인
섹션 제목: “5. 시뮬레이터 GUI에서 확인”시뮬레이터 GUI 가이드의 창 두 개 구성을 씁니다. 장비 창부터 준비합니다.
-
Reports & Events → Catalog에서 Load example과 Use on next start를 누릅니다. CEID 10 이벤트가 준비됩니다.
-
Spooling → Configuration에서 Load example을 누릅니다. 카탈로그와 선택에 S5F1, S6F11이 들어가고 Enable local spooling이 켜집니다. Use on next start를 누릅니다.


-
호스트를 연결하기 전에 장비 세션을 시작합니다. 통신이 없으므로 스풀링이 활성화됩니다.
-
Queue 탭에서 DATAID
1, CEID10을 넣고 Capture event를 누릅니다. 큐에 스트림 6, 펑션 11, W 비트가 있는 항목이 생깁니다. -
호스트 창에서 세션을 시작하고 Establish communication을 누릅니다.
-
호스트 창의 Spooling → Controls & Delivery에서 Transmit을 고르고 Submit data control을 누릅니다.
-
장비 창의 Controls & Delivery에서 대기 중인 제어를 고르고 Respond to selected control을 누릅니다. 이어서 Send next를 누릅니다.
-
호스트 창의 Reports & Events → Traffic → Events에서 받은 이벤트를 고르고 ACKC6
0으로 Acknowledge를 누릅니다. -
장비 창에서 대기 중인 전달을 고르고 Accept W1을 누릅니다. 큐가 비고 스풀링이 비활성으로 바뀝니다.
자주 생기는 오류
섹션 제목: “자주 생기는 오류”| 증상 | 원인 | 해결 |
|---|---|---|
Spool entry 1 was not captured. |
이전 실행의 저장 파일을 다시 썼습니다. | 새 경로를 쓰거나 저장 파일과 .lock 파일을 지웁니다. |
ConfigureSpooling이 Backpressure |
다른 프로세스가 같은 저장 파일의 잠금을 갖고 있습니다. | 그 프로세스를 끝내거나 다른 파일을 씁니다. |
ConfigureSpooling이 InvalidData |
파일이 손상됐거나, 저장된 카탈로그와 넘긴 카탈로그가 다릅니다. | 카탈로그를 파일과 맞추거나 새 파일로 시작합니다. |
ConfigureSpooling이 InvalidArgument |
새 스풀의 카탈로그가 비었거나 잘못되었거나 중복 항목이 있습니다. 또는 저장 경로에 파일 이름이 없거나, 경로를 주면서 GemSpoolOptions.MaxSerializedBytes를 144보다 작게 정했습니다. |
카탈로그 항목을 확인하고, 파일 이름까지 포함한 경로와 144 이상의 MaxSerializedBytes를 씁니다. |
CaptureSpool이 InvalidState |
스풀링을 구성하지 않았거나 SyncSpool로 동기화하지 않았습니다. |
이벤트를 처리한 뒤 SyncSpool을 부르고 다시 보관합니다. |
CaptureSpool이 InvalidArgument |
스트림 1, 응답(짝수) 펑션처럼 스풀링할 수 없는 메시지입니다. | 스트림 2~127의 primary 메시지만 보관합니다. |
보관 결과의 Selected가 false |
스풀링이 비활성이거나, 카탈로그·선택에 없는 메시지입니다. | 통신이 끊긴 상태인지, 선택에 그 메시지가 있는지 확인합니다. |
| S6F24에 RSDA 1 | 이전 전송이나 전달이 아직 끝나지 않았습니다. | 대기 중인 전달을 완료한 뒤 다시 요청합니다. |
| S6F24에 RSDA 2 | 보관된 메시지가 없습니다. | 정상 응답입니다. |
SendNextSpool이 OutOfRange |
스풀이 비어 전송 회차가 끝났습니다. | 더 보낼 항목이 없습니다. |
SendNextSpool이 Backpressure |
앞선 전달이 진행 중이거나 제거를 기다립니다. | CompleteSpoolDelivery로 앞선 전달을 끝냅니다. |
RespondSpool이 SpoolUnconfirmed |
저장 파일 쓰기를 확인하지 못했습니다. | ConfirmSpoolReply 뒤 같은 결과로 RespondSpool을 다시 부릅니다. |
호스트 요청이 InvalidState |
호스트 스풀링을 구성하지 않았거나 통신 전에 보냈습니다. | 시작 전에 ConfigureSpoolHost를 부르고, 통신 수립 뒤에 요청합니다. |
- 예제는 시나리오를 마치면 스스로 끝납니다.
- 실습에 쓴
equipment.spool과equipment.spool.lock을 지웁니다. - 시뮬레이터에서는 양쪽 창에서 Stop을 누릅니다. 시뮬레이터 스풀은 메모리에만 있습니다.
다음 단계
섹션 제목: “다음 단계”- 시뮬레이터 자동화에서 같은 흐름을 headless 시나리오와 MCP로 실행합니다.
- API 세부 사항은 SECS/GEM API 레퍼런스를 봅니다.