콘텐츠로 이동
무료 평가 시작

스풀링

스풀링(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은 스풀링의 각 단계를 애플리케이션이 직접 부르게 합니다. 아무것도 자동으로 보내지 않습니다.

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으로 거부됩니다.
터미널 창
dotnet build "$Examples\dotnet\PDComm.GemSpoolingDemo\PDComm.GemSpoolingDemo.csproj" -c Release "-p:PDCommSdk=$Sdk" --artifacts-path "$Root\build\managed"

기대 결과: Build succeeded.와 0 Error(s)가 출력됩니다.

이 예제는 다섯 번째 인수로 스풀 저장 파일 경로를 받습니다. 장비는 새 파일 경로를, 호스트는 -를 넘깁니다. 호스트는 자체 스풀을 갖지 않습니다.

터미널 창
# 터미널 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> -

기대 결과: 양쪽이 다음 줄을 출력하고 종료 코드 0으로 끝납니다. 장비 쪽 폴더에는 equipment.spool과 잠금 파일 equipment.spool.lock이 생깁니다.

PASS host active DOTNET SPOOLING PEER
LISTEN 127.0.0.1:54615
PASS equipment passive DOTNET SPOOLING PEER

장비는 시작하기 전에(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 전체 코드
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.");
}
}
}

같은 파일로 장비를 다시 실행해 봅니다.

터미널 창
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에서 확인합니다.

시뮬레이터 GUI 가이드의 창 두 개 구성을 씁니다. 장비 창부터 준비합니다.

  1. Reports & Events → Catalog에서 Load example과 Use on next start를 누릅니다. CEID 10 이벤트가 준비됩니다.

  2. Spooling → Configuration에서 Load example을 누릅니다. 카탈로그와 선택에 S5F1, S6F11이 들어가고 Enable local spooling이 켜집니다. Use on next start를 누릅니다.

    Spooling 작업공간 Configuration 탭에 예제 카탈로그와 실제 세션 구성이 표시된 장비 창Spooling 작업공간 Configuration 탭에 예제 카탈로그와 실제 세션 구성이 표시된 장비 창
  3. 호스트를 연결하기 전에 장비 세션을 시작합니다. 통신이 없으므로 스풀링이 활성화됩니다.

  4. Queue 탭에서 DATAID 1, CEID 10을 넣고 Capture event를 누릅니다. 큐에 스트림 6, 펑션 11, W 비트가 있는 항목이 생깁니다.

  5. 호스트 창에서 세션을 시작하고 Establish communication을 누릅니다.

  6. 호스트 창의 Spooling → Controls & Delivery에서 Transmit을 고르고 Submit data control을 누릅니다.

  7. 장비 창의 Controls & Delivery에서 대기 중인 제어를 고르고 Respond to selected control을 누릅니다. 이어서 Send next를 누릅니다.

  8. 호스트 창의 Reports & Events → Traffic → Events에서 받은 이벤트를 고르고 ACKC6 0으로 Acknowledge를 누릅니다.

  9. 장비 창에서 대기 중인 전달을 고르고 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을 누릅니다. 시뮬레이터 스풀은 메모리에만 있습니다.