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

공정 프로그램

공정 프로그램(process program)은 장비가 제품을 처리하는 방법을 담은 데이터입니다. 현장에서는 흔히 레시피(recipe)라고 부릅니다. 각 프로그램은 PPID(process program ID)로 구별하고, 내용은 PPBODY에 담습니다. GEM은 스트림 7(S7) 메시지로 호스트와 장비가 프로그램을 주고받는 방법을 정합니다.

S7에는 두 종류가 있습니다.

  • 비정형(unformatted) 프로그램: PPBODY를 해석하지 않는 하나의 값으로 주고받습니다. S7F1S7F6, S7F17S7F20을 씁니다.
  • 정형(formatted) 프로그램: 명령 코드(CCODE)와 파라미터(PPARM) 목록으로 구조화한 프로그램입니다. S7F21~S7F32를 씁니다.

이 튜토리얼에서는 두 방식을 모두 실행합니다. 마지막으로 시뮬레이터 GUI에서 같은 요청을 확인합니다.

  • 비정형 프로그램의 디렉터리 조회, 전송 허가(grant), 전송, 요청, 삭제를 실행합니다.
  • 받은 S7 요청에 애플리케이션이 결정하고 응답하는 구조와 PPGNT·ACKC7 코드를 익힙니다.
  • 저장 파일을 쓸 때의 내구성(durability) 결과와 PROGRAM_UNCONFIRMED 처리 방법을 이해합니다.
  • 정형 프로그램의 능력 조회, 전송, 조회, 검증 요청, 검증 보고, 검증 협상을 실행합니다.
  • 장비의 로컬 저장소, 선택, dry run, 실행 흐름이 실제 장비를 움직이지 않는다는 점을 확인합니다.
  • 개발 환경 준비에서 정한 변수 PDCOMM_ROOT, PDCOMM_SDK, PDCOMM_NATIVE, PDCOMM_EXAMPLES(PowerShell은 $Root, $Sdk, $Native, $Examples)
  • .NET 10 SDK
  • 첫 통신에서 빌드한 C# 튜토리얼 예제 PDComm.Tutorial
메시지 보내는 쪽 내용 응답 코드
S7F1/S7F2 Process Program Load Inquire/Grant 호스트 또는 장비 보낼 PPID와 길이를 알리고 허가를 받습니다. PPGNT
S7F3/S7F4 Process Program Send/Acknowledge 호스트 또는 장비 PPID와 PPBODY를 보냅니다. ACKC7
S7F5/S7F6 Process Program Request/Data 호스트 또는 장비 PPID를 요청하고 PPBODY를 받습니다. -
S7F17/S7F18 Delete Process Program Send/Acknowledge 호스트 지울 PPID 목록을 보냅니다. 빈 목록은 전체 삭제를 뜻합니다. ACKC7
S7F19/S7F20 Current EPPD Request/Data 호스트 장비에 저장된 PPID 목록을 요청합니다. -

받는 쪽 애플리케이션이 고르는 코드는 다음과 같습니다.

값 PPGNT(S7F2) ACKC7(S7F4, S7F18)
0 허가 수락
1 이미 있음 허가받지 않음
2 공간 부족 길이 오류
3 잘못된 PPID 행렬 넘침(matrix overflow)
4 사용 중 PPID 없음
5 받지 않음 지원하지 않는 모드

PDComm은 프로그램 요청에 자동으로 답하지 않습니다. GemEquipment.ConfigurePrograms로 저장소를 구성하면, 받은 S7F1·S7F3·S7F5·S7F17·S7F19는 ProgramReceived 결과로 올라옵니다. 애플리케이션은 내용을 확인하고 GemEquipment.RespondProgram로 결정(decision)과 저장 모드(Create, Replace, Upsert)를 넘깁니다.

  • 저장소의 자체 검사가 애플리케이션의 수락보다 우선합니다. 허가 정책, 저장소 한도, 선언한 길이, 저장 모드, PPID 존재 여부에 따라 수락 결정이라도 거부 코드가 나갈 수 있습니다.
  • 프로그램을 저장하거나 수락 응답을 보내도 공정이 선택되거나 실행되지 않습니다.
  • 수락한 S7F3과 S7F17은 응답을 보내기 전에 저장소에 먼저 반영됩니다.
  • 받은 S7F17에는 항상 S7F18로 답합니다. PPID 수가 GemProgramOptions.MaxRequestIds보다 많으면 ACKC7 3(행렬 넘침), 저장되지 않은 PPID나 저장소가 받을 수 있는 것보다 긴 PPID가 있으면 ACKC7 4(PPID 없음)입니다. 같은 PPID가 여러 번 나오면 한 번만 지웁니다. 거부한 요청은 아무것도 지우지 않습니다.
터미널 창
dotnet build "$Examples\dotnet\PDComm.GemProgramsDemo\PDComm.GemProgramsDemo.csproj" -c Release "-p:PDCommSdk=$Sdk" --artifacts-path "$Root\build\managed"

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

터미널 두 개에서 장비를 먼저 시작하고, 출력된 주소로 호스트를 연결합니다.

터미널 창
# 터미널 A: 장비
dotnet "$Root\build\managed\bin\PDComm.GemProgramsDemo\release\PDComm.GemProgramsDemo.dll" $Native equipment passive 127.0.0.1:0
# 터미널 B: 호스트
dotnet "$Root\build\managed\bin\PDComm.GemProgramsDemo\release\PDComm.GemProgramsDemo.dll" $Native host active 127.0.0.1:<port>

기대 결과: 장비는 LISTEN 127.0.0.1:<port>를 먼저 출력합니다. 교환이 끝나면 양쪽이 다음 줄을 출력하고 종료 코드 0으로 끝납니다.

PASS host active DOTNET PROGRAMS PEER
PASS equipment passive DOTNET PROGRAMS PEER

예제는 같은 코드로 호스트와 장비를 모두 실행합니다. 이 예제는 작업자(worker)를 쓰지 않고 끝점(GemHost, GemEquipment)을 직접 Poll하는 방식입니다. 시작하기 전에 양쪽이 저장소를 메모리로만 구성하고(ConfigurePrograms에 경로를 주지 않음), 프로그램 하나씩을 넣습니다(SeedProgram).

  • 장비: PPID BASE, PPBODY ASCII "BASE"
  • 호스트: PPID HOST, PPBODY Binary 48(문자 H)

통신이 수립되면 호스트가 다섯 요청을 차례로 보냅니다(SendStep).

순서 호스트 요청 장비의 응답
1 S7F19 디렉터리 조회 S7F20: BASE
2 S7F1 TRANSFER 4바이트 허가 요청 S7F2: PPGNT 0
3 S7F3 TRANSFER = U4 42 전송 S7F4: ACKC7 0, TRANSFER 저장
4 S7F5 BASE 요청 S7F6: ASCII "BASE"
5 S7F17 BASE 삭제 S7F18: ACKC7 0, BASE 삭제

호스트의 요청을 모두 처리한 장비가 이번에는 요청을 세 번 보냅니다. S7F1·S7F3·S7F5는 양방향 메시지이기 때문입니다.

순서 장비 요청 호스트의 응답
1 S7F1 UPLOAD 9바이트 허가 요청 S7F2: PPGNT 0
2 S7F3 UPLOAD = ASCII "EQUIPMENT" 전송 S7F4: ACKC7 0, UPLOAD 저장
3 S7F5 HOST 요청 S7F6: Binary 48

마지막으로 호스트가 상태 변수 1을 S1F3으로 읽어, 장비가 모든 작업을 끝냈는지 확인합니다. 종료할 때 장비 저장소에는 TRANSFER 하나, 호스트 저장소에는 HOST와 UPLOAD 두 개가 남아야 합니다(VerifyCatalog). 모든 쓰기의 내구성은 메모리 전용인 Volatile입니다.

양쪽 응답 코드는 Respond에서 RespondProgram(result, GemProgramPutMode.Upsert, 0)로 정합니다. 결정 0은 허가 또는 수락입니다. 호스트 쪽 요청 API는 GemHost.RequestProgramDirectory, GemHost.GrantProgram, GemHost.SendProgram, GemHost.RequestProgram, GemHost.DeletePrograms입니다.

PDComm.GemProgramsDemo/Program.cs 전체 코드
PDComm.GemProgramsDemo/Program.cs
using PDComm;
if (args.Length != 4 || !Path.IsPathFullyQualified(args[0]) ||
args[1] is not ("host" or "equipment") ||
args[2] is not ("active" or "passive"))
{
Console.Error.WriteLine(
"Usage: PDComm.GemProgramsDemo <absolute-native-library-path> <host|equipment> <active|passive> <numeric-address>");
return 2;
}
PdcommRuntime.Initialize(args[0]);
bool isHost = args[1] == "host";
HsmsMode mode = args[2] == "active" ? HsmsMode.Active : HsmsMode.Passive;
using GemEndpoint endpoint = isHost
? GemHost.Create(args[3], Options.Transport(mode), Options.Host)
: GemEquipment.Create(args[3], Options.Transport(mode), Options.Equipment);
ConfigurePrograms(endpoint);
SeedProgram(endpoint, isHost);
if (endpoint is GemEquipment equipment)
{
using SecsItem value = SecsItem.FromF4([21.5f], Options.Limits);
equipment.RegisterStatus(1, "Temperature", "C", value);
}
endpoint.Start();
if (mode == HsmsMode.Passive)
{
Console.WriteLine($"LISTEN {endpoint.GetLocalAddress()}");
Console.Out.Flush();
}
int total = isHost ? 5 : 3;
int incoming = isHost ? 3 : 5;
int step = 0;
int served = 0;
bool began = false;
bool communicating = false;
bool started = false;
bool barrier = false;
bool stopping = false;
HsmsToken pending = default;
DateTime deadline = DateTime.UtcNow.AddSeconds(30);
while (DateTime.UtcNow < deadline)
{
endpoint.Poll();
HsmsEvent? transportEvent;
while ((transportEvent = endpoint.NextEvent()) is not null)
{
using (transportEvent)
using (GemResult result = endpoint.HandleEvent(transportEvent))
{
GemResultInfo info = result.GetInfo();
if (info.Kind is GemResultKind.Failed or GemResultKind.InvalidResponse or
GemResultKind.Unhandled)
throw new InvalidOperationException(
$"Program protocol failed: kind={info.Kind}, code={info.Code}, request={info.RequestKind}.");
if (info.Kind == GemResultKind.Primary && info.Outcome == GemOutcome.ProgramReceived)
{
if (served >= incoming)
throw new InvalidOperationException("The peer sent an extra program request.");
VerifyRequest(result, !isHost, served);
Respond(endpoint, result);
served++;
}
else if (info.Kind == GemResultKind.Response)
{
if (info.RequestKind == GemRequestKind.Establish)
{
if (!isHost || info.Outcome != GemOutcome.CommunicationEstablished)
throw new InvalidOperationException("Communication establishment failed.");
communicating = true;
}
else if (info.RequestKind == GemRequestKind.StatusValues)
{
if (!isHost || !barrier || step != total || served != incoming ||
info.Outcome != GemOutcome.ValuesReceived)
throw new InvalidOperationException("The completion barrier failed.");
endpoint.Stop();
stopping = true;
}
else
{
if (step >= total || info.Token != pending)
throw new InvalidOperationException("Program response correlation failed.");
VerifyResponse(result, isHost, step);
step++;
if (step < total) pending = SendStep(endpoint, isHost, step);
}
}
else if (!isHost && info.Outcome == GemOutcome.StatusReplied &&
(step != total || served != incoming))
{
throw new InvalidOperationException("The host ended before all program work completed.");
}
}
}
GemState state = endpoint.GetState();
if (state.Communication == GemCommunicationState.Communicating)
communicating = true;
HsmsState transport = endpoint.GetTransportState();
if (isHost && !began && transport.State == HsmsConnectionState.Selected)
{
pending = endpoint.BeginCommunication();
began = true;
}
if (communicating && !started && (isHost || served == incoming))
{
pending = SendStep(endpoint, isHost, 0);
started = true;
}
if (isHost && step == total && served == incoming && !barrier)
{
pending = ((GemHost)endpoint).RequestStatusValues([1]);
barrier = true;
}
if (!isHost && communicating && transport.State != HsmsConnectionState.Selected && !stopping)
{
if (step != total || served != incoming)
throw new InvalidOperationException("The host disconnected before the program exchange completed.");
endpoint.Stop();
stopping = true;
}
if (stopping && endpoint.GetTransportState().Closed)
{
VerifyCatalog(endpoint, isHost);
Console.WriteLine($"PASS {args[1]} {args[2]} DOTNET PROGRAMS PEER");
return 0;
}
Thread.Sleep(1);
}
throw new TimeoutException(
$"Program peer timed out: role={args[1]}, step={step}/{total}, served={served}/{incoming}.");
static void ConfigurePrograms(GemEndpoint endpoint)
{
if (endpoint is GemHost host) host.ConfigurePrograms(Options.Programs);
else ((GemEquipment)endpoint).ConfigurePrograms(Options.Programs);
}
static void SeedProgram(GemEndpoint endpoint, bool host)
{
using SecsItem id = SecsItem.FromAscii(host ? "HOST" : "BASE", Options.Limits);
using SecsItem body = host
? SecsItem.FromBinary([(byte)'H'], Options.Limits)
: SecsItem.FromAscii("BASE", Options.Limits);
GemProgramWriteInfo result = endpoint is GemHost hostEndpoint
? hostEndpoint.PutProgram(id, body, GemProgramPutMode.Upsert)
: ((GemEquipment)endpoint).PutProgram(id, body, GemProgramPutMode.Upsert);
if (!result.Applied || result.Durability != GemProgramDurability.Volatile)
throw new InvalidOperationException("The initial program was not stored in memory.");
}
static HsmsToken SendStep(GemEndpoint endpoint, bool host, int step)
{
if (host && step == 0) return ((GemHost)endpoint).RequestProgramDirectory();
string name = host
? step is 3 or 4 ? "BASE" : "TRANSFER"
: step == 2 ? "HOST" : "UPLOAD";
using SecsItem id = SecsItem.FromAscii(name, Options.Limits);
return (host, step) switch
{
(true, 1) => ((GemHost)endpoint).GrantProgram(id, 4),
(true, 2) => Send(endpoint, id, SecsItem.FromU4([42], Options.Limits)),
(true, 3) => ((GemHost)endpoint).RequestProgram(id),
(true, 4) => ((GemHost)endpoint).DeletePrograms([id]),
(false, 0) => ((GemEquipment)endpoint).GrantProgram(id, 9),
(false, 1) => Send(endpoint, id, SecsItem.FromAscii("EQUIPMENT", Options.Limits)),
(false, 2) => ((GemEquipment)endpoint).RequestProgram(id),
_ => throw new ArgumentOutOfRangeException(nameof(step)),
};
}
static HsmsToken Send(GemEndpoint endpoint, SecsItem id, SecsItem body)
{
using (body)
return endpoint is GemHost host
? host.SendProgram(id, body)
: ((GemEquipment)endpoint).SendProgram(id, body);
}
static GemRequestKind RequestKind(bool host, int step) => host
? step switch
{
0 => GemRequestKind.ProgramDirectory,
1 => GemRequestKind.ProgramGrant,
2 => GemRequestKind.ProgramSend,
3 => GemRequestKind.ProgramRequest,
4 => GemRequestKind.ProgramDelete,
_ => throw new ArgumentOutOfRangeException(nameof(step)),
}
: step switch
{
0 => GemRequestKind.ProgramGrant,
1 => GemRequestKind.ProgramSend,
2 => GemRequestKind.ProgramRequest,
_ => throw new ArgumentOutOfRangeException(nameof(step)),
};
static void VerifyRequest(GemResult result, bool senderHost, int step)
{
GemResultInfo generic = result.GetInfo();
GemProgramInfo program = result.GetProgramInfo();
GemRequestKind requestKind = RequestKind(senderHost, step);
if (generic.RequestKind != RequestKind(senderHost, step) || !program.IsRequest ||
program.Responded || program.RequestKind != generic.RequestKind)
throw new InvalidOperationException("Unexpected incoming program request.");
nuint expectedCount = requestKind == GemRequestKind.ProgramDirectory ? 0u : 1u;
if (program.IdCount != expectedCount || generic.EntryCount != expectedCount)
throw new InvalidOperationException("Unexpected incoming program identifier count.");
if (expectedCount != 0)
{
string expectedId = senderHost
? step is 3 or 4 ? "BASE" : "TRANSFER"
: step == 2 ? "HOST" : "UPLOAD";
using SecsItem id = result.GetProgramId(0);
using SecsItem expected = SecsItem.FromAscii(expectedId, Options.Limits);
if (!id.Encode().SequenceEqual(expected.Encode()))
throw new InvalidOperationException("Unexpected incoming program identifier.");
}
if (requestKind == GemRequestKind.ProgramGrant &&
program.DeclaredLength != (senderHost ? 4u : 9u))
throw new InvalidOperationException("Unexpected incoming program grant length.");
if (requestKind == GemRequestKind.ProgramSend)
{
using SecsItem? body = result.GetProgramBody();
using SecsItem expected = senderHost
? SecsItem.FromU4([42], Options.Limits)
: SecsItem.FromAscii("EQUIPMENT", Options.Limits);
if (body is null || !body.Encode().SequenceEqual(expected.Encode()))
throw new InvalidOperationException("Unexpected incoming program body.");
}
}
static void Respond(GemEndpoint endpoint, GemResult result)
{
if (endpoint is GemHost host)
host.RespondProgram(result, GemProgramPutMode.Upsert, 0);
else
((GemEquipment)endpoint).RespondProgram(result, GemProgramPutMode.Upsert, 0);
}
static void VerifyResponse(GemResult result, bool senderHost, int step)
{
GemResultInfo generic = result.GetInfo();
GemProgramInfo program = result.GetProgramInfo();
if (generic.Kind != GemResultKind.Response || generic.RequestKind != RequestKind(senderHost, step) ||
program.IsRequest || program.RequestKind != generic.RequestKind)
throw new InvalidOperationException("Unexpected program response.");
if (generic.RequestKind is GemRequestKind.ProgramGrant or GemRequestKind.ProgramSend or
GemRequestKind.ProgramDelete)
{
if (program.Acknowledgement != 0)
throw new InvalidOperationException("The program request was rejected.");
}
else if (generic.RequestKind == GemRequestKind.ProgramRequest)
{
using SecsItem? body = result.GetProgramBody();
using SecsItem expected = senderHost
? SecsItem.FromAscii("BASE", Options.Limits)
: SecsItem.FromBinary([(byte)'H'], Options.Limits);
if (body is null || !body.Encode().SequenceEqual(expected.Encode()))
throw new InvalidOperationException("The program response body differs.");
}
else
{
if (program.IdCount != 1)
throw new InvalidOperationException("The program directory size differs.");
using SecsItem id = result.GetProgramId(0);
using SecsItem expected = SecsItem.FromAscii("BASE", Options.Limits);
if (!id.Encode().SequenceEqual(expected.Encode()))
throw new InvalidOperationException("The program directory identifier differs.");
}
}
static void VerifyCatalog(GemEndpoint endpoint, bool host)
{
GemProgramState state = endpoint is GemHost hostEndpoint
? hostEndpoint.GetProgramState()
: ((GemEquipment)endpoint).GetProgramState();
nuint expected = host ? 2u : 1u;
if (state.ProgramCount != expected || state.Persistent || state.GrantCount != 0 ||
state.ReservedBodyBytes != 0)
throw new InvalidOperationException("The final in-memory program catalog differs.");
}
internal static class Options
{
internal static readonly SecsLimits Limits = new(4096, 4096, 256, 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,
};
internal static readonly GemProgramOptions Programs = new()
{
MaxPrograms = 16,
MaxIdentifierBytes = 120,
MaxTotalIdentifierBytes = 4096,
MaxBodyBytes = 4096,
MaxTotalBodyBytes = 16384,
MaxRequestIds = 32,
MaxGrants = 8,
MaxReservedBodyBytes = 8192,
MaxPendingIds = 64,
MaxPendingIdentifierBytes = 4096,
MaxSerializedBytes = 65536,
GrantPolicy = GemProgramGrantPolicy.Optional,
};
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,
};
}

ConfigurePrograms에 파일 경로를 주면 저장소가 그 파일에 저장됩니다. 프로그램을 바꿀 때마다 쓰기 결과의 내구성(GemProgramDurability)을 확인합니다.

내구성 의미
None 이 작업에 저장 변경이 없습니다. 거부했거나 읽기 요청입니다.
Volatile 저장 파일이 없어 메모리에만 반영했습니다.
Synced 파일에 쓰고 상위 폴더까지 동기화했습니다.
Unconfirmed 변경은 반영했지만 저장을 확인하지 못했습니다.

파일 저장을 확인하지 못하면 RespondProgram은 응답을 보내지 않고 PdcommStatus.ProgramUnconfirmed 상태로 끝납니다. 변경은 이미 저장소에 있습니다. 이때는 다음 순서로 처리합니다.

  1. 필요하면 GemEquipment.ResyncPrograms로 저장소와 파일을 다시 맞춥니다.
  2. GemEquipment.ConfirmProgramReply로 보류한 응답을 확인합니다.
  3. 같은 인수로 RespondProgram을 다시 호출해 저장해 둔 응답을 보냅니다.

전송 실패(예: Backpressure)로 응답을 못 보냈을 때도 같은 모드와 결정으로 다시 호출합니다. 작업을 처음부터 다시 하지 않습니다.

Qt 예제 묶음의 programs.cpp도 이 순서를 따릅니다. 이 예제는 장비 저장소를 명령줄의 SNAPSHOT 파일에 저장하고(configurePrograms(options(false), path)), GEM 끝점을 QThread 작업 스레드에서 실행하며, 응답 검사를 Qt 메인 스레드로 넘깁니다. 다만 PDComm 내부 시험과 시나리오 정의를 공유하므로 SDK만으로는 빌드되지 않고, 이 문서에도 싣지 않습니다. 빌드하고 실행할 수 있는 Qt 예제는 원격 명령의 commands.cpp와 보고서와 이벤트의 reports.cpp를 봅니다.

정형 프로그램 메시지는 방향이 정해져 있습니다.

메시지 방향 내용
S7F21/S7F22 Process Capabilities Request/Data 호스트 → 장비 장비가 이해하는 명령 코드와 파라미터 형식
S7F23/S7F24 Formatted Process Program Send/Acknowledge 호스트 → 장비 정형 프로그램 전송
S7F25/S7F26 Formatted Process Program Request/Data 호스트 → 장비 정형 프로그램 조회
S7F27/S7F28 Process Program Verification Send/Acknowledge 장비 → 호스트 검증 결과와 문제 목록 보고
S7F29/S7F30 Process Program Verification Inquire/Grant 장비 → 호스트 검증 보고를 보내도 되는지 묻기
S7F31/S7F32 Verification Request Send/Acknowledge 호스트 → 장비 정형 프로그램 검증 요청

C# 튜토리얼 예제 PDComm.Tutorial은 이 메시지를 대화형 명령으로 실행합니다. 예제는 PPID PP-DEMO, MDLN PDComm, SOFTREV 0.1.0, 파라미터 없는 명령 RUN 하나로 된 프로그램을 씁니다. 장비 쪽은 시작할 때 조회용 항목과 능력 카탈로그를 등록합니다(SeedQuery, ReplaceCapabilities).

터미널 두 개에서 양쪽을 모두 C# 예제로 실행합니다.

터미널 창
# 터미널 A: 장비
dotnet "$Root\build\managed\bin\PDComm.Tutorial\release\PDComm.Tutorial.dll" $Native equipment passive 127.0.0.1:5000
# 터미널 B: 호스트
dotnet "$Root\build\managed\bin\PDComm.Tutorial\release\PDComm.Tutorial.dll" $Native host active 127.0.0.1:5000

호스트 터미널에 명령을 한 줄씩 입력하고 응답을 기다립니다. 먼저 establish로 통신을 수립합니다.

establish
RESULT kind=2 request=1 outcome=2 status=CommunicationEstablished
SECS-II 0102210100010241065044436F6D6D4105302E312E30
capability
RESULT kind=2 request=39 outcome=65 status=FormattedCapabilityResult
SECS-II 010541065044436F6D6D4105302E312E30A50104B104000010000101010B410352554E410352756E250101650100A900A900A900A9020000A900A9000100
transfer
RESULT kind=2 request=36 outcome=59 status=FormattedTransferAck
SECS-II 210100
query
RESULT kind=2 request=37 outcome=61 status=FormattedQueryResult
SECS-II 0104410750502D44454D4F41065044436F6D6D4105302E312E3001010102410352554E0100
verify-request
RESULT kind=2 request=38 outcome=63 status=FormattedVerificationRequestAck
SECS-II 210100

장비 터미널에는 받은 요청이 kind=1(primary)로 나타납니다. 장비 예제는 받은 정형 요청에 바로 응답합니다(RespondCapabilityQuery, RespondTransferAndStore, RespondQuery, RespondVerificationRequest).

RESULT kind=1 request=0 outcome=2 status=CommunicationEstablished
SECS-II 0100
RESULT kind=1 request=39 outcome=64 status=FormattedCapabilityReceived
RESULT kind=1 request=36 outcome=58 status=FormattedTransferReceived
SECS-II 0104410750502D44454D4F41065044436F6D6D4105302E312E3001010102410352554E0100
RESULT kind=1 request=37 outcome=60 status=FormattedQueryReceived
SECS-II 410750502D44454D4F
RESULT kind=1 request=38 outcome=62 status=FormattedVerificationRequestReceived
SECS-II 0104410750502D44454D4F41065044436F6D6D4105302E312E3001010102410352554E0100

이제 장비 터미널에 장비 명령을 입력합니다.

verify-report
RESULT kind=2 request=35 outcome=57 status=FormattedVerificationReportAck
negotiate
RESULT kind=2 request=40 outcome=67 status=FormattedVerificationGrantReceived
SECS-II 210100
RESULT kind=2 request=35 outcome=68 status=FormattedVerificationNegotiationAck
store
STORE entries=1
dryrun
DRYRUN outcome=Pass issues=0
execute
DRYRUN outcome=Pass issues=0
APPLICATION DEMO: acknowledge RUN without controlling equipment
EXECUTION state=Completed

호스트 터미널에는 장비가 보낸 검증 보고와 문의가 나타납니다.

RESULT kind=1 request=35 outcome=56 status=FormattedVerificationReportReceived
SECS-II 0102410750502D44454D4F01010103B10400000001B10400000001410A64656D6F206973737565
RESULT kind=1 request=40 outcome=66 status=FormattedVerificationInquireReceived
SECS-II B1040000000D
RESULT kind=1 request=35 outcome=56 status=FormattedVerificationReportReceived
SECS-II 0102410750502D44454D4F0100
명령 입력하는 쪽 메시지와 결과
capability 호스트 S7F21 → S7F22. 장비 능력 카탈로그(RUN 명령)를 받습니다.
transfer 호스트 S7F23 → S7F24 ACK 0. 장비는 받은 프로그램을 Upsert로 저장합니다.
query 호스트 S7F25 → S7F26. 장비에 등록된 PP-DEMO의 RUN 내용을 받습니다.
verify-request 호스트 S7F31 → S7F32 ACK 0.
verify-report 장비 S7F27 → S7F28. 문제 하나(demo issue)를 보고합니다. S7F28은 본문이 없는 응답입니다.
negotiate 장비 S7F29(보낼 보고서 길이 13바이트) → S7F30 PPGNT 0 → 준비한 S7F27 → S7F28.
store 장비 네트워크 없이 로컬 정형 저장소에 PP-DEMO를 넣습니다.
dryrun 장비 저장된 PP-DEMO를 선택하고 능력 카탈로그와 대조합니다. 문제가 없으면 Pass입니다.
execute 장비 dry run 결과로 실행을 시작합니다. 예제 애플리케이션이 RUN을 Applied로 처리하고 실행을 Completed로 끝냅니다.

request와 outcome 숫자는 GemRequestKind와 GemOutcome 값이고, status는 그 이름입니다.

정형 프로그램 API는 끝점의 FormattedPrograms 속성(GemFormattedPrograms)으로 씁니다. 장비 측 응답 API는 GemFormattedPrograms.RespondTransferAndStore, GemFormattedPrograms.RespondQuery이고, 로컬 실행 흐름은 GemFormattedPrograms.SelectProgram, GemFormattedPrograms.DryRun, GemFormattedPrograms.StartExecution로 시작합니다.

PDComm.Tutorial/Program.cs 전체 코드
examples/tutorial/PDComm.Tutorial/Program.cs
using System.Collections.Concurrent;
using PDComm;
if (args.Length is < 4 or > 5 || args[1] is not ("host" or "equipment") ||
args[2] is not ("active" or "passive") || args.Length == 5 && args[4] != "--auto")
{
Console.Error.WriteLine("Usage: PDComm.Tutorial <absolute-library-path> <host|equipment> <active|passive> <numeric-IP:port> [--auto]");
return 2;
}
try
{
PdcommRuntime.Initialize(Path.GetFullPath(args[0]));
bool automatic=args.Length==5; var mode=args[2]=="active"?HsmsMode.Active:HsmsMode.Passive;
using GemEndpoint endpoint=args[1]=="host"?
GemHost.Create(args[3],TutorialOptions.Transport(mode),TutorialOptions.HostOptions()):
GemEquipment.Create(args[3],TutorialOptions.Transport(mode),TutorialOptions.EquipmentOptions());
var api=endpoint.FormattedPrograms; TutorialOptions.Configuration().Configure(endpoint);
using var id=SecsItem.FromAscii("PP-DEMO",TutorialOptions.Limits);
using var code=SecsItem.FromAscii("RUN",TutorialOptions.Limits);
var payload=new GemFormattedProgramPayload(id,"PDComm","0.1.0",[new(code,[])]);
if(endpoint is GemEquipment equipment)
{
api.SeedQuery(payload);
using var temperature=SecsItem.FromF4([21.5f],TutorialOptions.Limits);
using var lot=SecsItem.FromAscii("LOT-001",TutorialOptions.Limits);
equipment.RegisterStatus(1,"Temperature","C",temperature); equipment.RegisterStatus(2,"Lot","",lot);
api.ReplaceCapabilities("PDComm","0.1.0",4,4096,[TutorialOptions.Capability()]);
}
endpoint.Start();
if(mode==HsmsMode.Passive) Console.WriteLine($"LISTEN {endpoint.GetLocalAddress()}");
var input=new ConcurrentQueue<string>();
if(!automatic)
{
Console.WriteLine("Commands: state, establish, identity, values, names, online, offline, linktest, directories, alarms, transfer, query, verify-request, verify-report, capability, negotiate, store, dryrun, execute, quit");
_=Task.Run(()=> { string? line; while((line=Console.ReadLine()) is not null) { input.Enqueue(line); if(line=="quit") return; } input.Enqueue("quit"); });
}
int step=0,received=0; DateTime deadline=DateTime.UtcNow.AddSeconds(20),finish=deadline; bool finishing=false,quit=false;
while(!quit)
{
endpoint.Poll(); HsmsEvent? current;
while((current=endpoint.NextEvent()) is not null)
{
using(current) using(var result=endpoint.HandleEvent(current))
{
var info=result.GetInfo();
if(info.Kind!=GemResultKind.Ignored)
{
Console.WriteLine($"RESULT kind={(uint)info.Kind} request={(uint)info.RequestKind} outcome={(uint)info.Outcome} status={info.Outcome}");
using var body=result.GetBody(); if(body is not null) Console.WriteLine("SECS-II "+Convert.ToHexString(body.Encode()));
}
if(info.Kind==GemResultKind.Unhandled && current.GetInfo().Header is { ReplyExpected: true } primary)
{
// No GEM handler took this primary. Abort it with SxF0 so the peer does not
// wait for T3 and the primary does not keep holding an incoming slot.
try
{
endpoint.ReplyRaw(current,primary.Stream,0,false,ReadOnlySpan<byte>.Empty);
Console.WriteLine($"ABORT S{primary.Stream}F0 for S{primary.Stream}F{primary.Function}");
}
catch(PdcommException error) { Console.Error.WriteLine($"REPLY ERROR status={error.Status}"); }
}
if(info.Kind==GemResultKind.Primary)
{
switch(info.RequestKind)
{
case GemRequestKind.FormattedTransfer: api.RespondTransferAndStore(result,0,GemFormattedProgramStoreAction.Upsert); break;
case GemRequestKind.FormattedQuery: api.RespondQuery(result); break;
case GemRequestKind.FormattedVerificationRequest: api.RespondVerificationRequest(result,0); break;
case GemRequestKind.FormattedVerificationReport: api.RespondVerificationReport(result); break;
case GemRequestKind.FormattedCapability: api.RespondCapabilityQuery(result); break;
case GemRequestKind.FormattedVerificationNegotiation: api.RespondVerification(result,0); break;
}
if(automatic && endpoint is GemEquipment && ++received==3) { finishing=true; finish=DateTime.UtcNow.AddMilliseconds(500); }
}
if(automatic && endpoint is GemHost host && info.Kind==GemResultKind.Response)
{
if(step==1 && info.Outcome==GemOutcome.CommunicationEstablished) { host.AreYouOnline(); step=2; }
else if(step==2 && info.Outcome==GemOutcome.EquipmentIdentity) { host.RequestStatusValues([1,2]); step=3; }
else if(step==3 && info.Outcome==GemOutcome.ValuesReceived) { finishing=true; finish=DateTime.UtcNow.AddMilliseconds(500); }
}
}
}
if(automatic && endpoint is GemHost autoHost && step==0 && endpoint.GetTransportState().State==HsmsConnectionState.Selected) { autoHost.BeginCommunication(); step=1; }
if(automatic && finishing && DateTime.UtcNow>=finish) break;
if(automatic && DateTime.UtcNow>deadline) throw new TimeoutException("Tutorial peer response deadline expired");
if(input.TryDequeue(out string? line))
{
try
{
uint[] ids=[1,2];
if(line.StartsWith("values ",StringComparison.Ordinal) || line.StartsWith("names ",StringComparison.Ordinal))
{
string[] fields=line.Split(' ',StringSplitOptions.RemoveEmptyEntries);
ids=fields.Skip(1).Select(uint.Parse).ToArray(); line=fields[0];
}
if(line=="quit") quit=true;
else if(line=="state") Console.WriteLine($"STATE tcp={endpoint.GetTransportState().State} gem={endpoint.GetState().Communication}");
else if(line=="linktest") endpoint.Linktest();
else if(line=="transfer") api.SubmitTransfer(payload);
else if(line=="query") api.SubmitQuery(id);
else if(line=="verify-request") api.SubmitVerificationRequest(payload);
else if(line=="verify-report") api.SubmitVerificationReport(id,[new(1,1,"demo issue")]);
else if(line=="capability") api.SubmitCapabilityQuery();
else if(line=="negotiate") { using var prepared=api.PrepareVerification(id,[]); api.SubmitPreparedVerification(prepared); }
else if(line=="store") { api.PutProgram(payload,GemFormattedProgramStoreAction.Upsert); Console.WriteLine($"STORE entries={api.GetStoreInfo().EntryCount}"); }
else if(line is "dryrun" or "execute")
{
var selection=api.SelectProgram(id); using var report=api.DryRun(selection,new(1,32,8192));
Console.WriteLine($"DRYRUN outcome={(GemFormattedProgramDryRunOutcome)report.GetInfo().Outcome} issues={report.GetInfo().IssueCount}");
if(line=="execute")
{
using var start=api.StartExecution(selection,report);
api.SubmitActionResult(GemFormattedPrograms.GetActionToken(start.Action),GemFormattedProgramActionResult.Accepted);
using var action=api.StepExecution(start.Token);
Console.WriteLine("APPLICATION DEMO: acknowledge RUN without controlling equipment");
api.SubmitActionResult(GemFormattedPrograms.GetActionToken(action),GemFormattedProgramActionResult.Applied);
api.CompleteExecution(start.Token,1,GemFormattedProgramTerminalOutcome.Complete,ReadOnlyMemory<byte>.Empty);
using var snapshot=api.GetExecutionSnapshot(); Console.WriteLine($"EXECUTION state={(GemFormattedProgramExecutionState)snapshot.GetInfo().State}");
}
}
else if(endpoint is GemHost host)
{
switch(line)
{
case "establish": host.BeginCommunication(); break;
case "identity": host.AreYouOnline(); break;
case "values": host.RequestStatusValues(ids); break;
case "names": host.RequestStatusNames(ids); break;
case "online": host.RequestOnline(); break;
case "offline": host.RequestOffline(); break;
case "directories": host.RequestDataDirectory([]); host.RequestEventDirectory([]); break;
case "alarms": host.RequestAlarmList([]); break;
default: throw new ArgumentException("Unknown command");
}
}
else throw new ArgumentException("This command requires the Host role");
}
catch(Exception error) { Console.Error.WriteLine("COMMAND ERROR "+error.Message); }
}
Thread.Sleep(2);
}
endpoint.Stop(); if(automatic) Console.WriteLine($"PASS tutorial {args[1]} {args[2]}"); return 0;
}
catch(Exception error) { Console.Error.WriteLine(error); return 1; }

시뮬레이터 GUI 가이드의 창 두 개 구성을 그대로 씁니다.

  1. 장비 창의 Process Programs → Catalog 탭에서 Load example을 누릅니다. Equipment program draft에 PPID DEMO-PP, PPBODY DEMO 항목이 생깁니다. Use on next start를 누른 뒤 장비 세션을 시작합니다. 시작하면 Actual session store에 같은 항목이 보입니다.

    Process Programs 작업공간 Catalog 탭에 DEMO-PP 프로그램이 초안과 실제 세션 저장소에 표시된 장비 창Process Programs 작업공간 Catalog 탭에 DEMO-PP 프로그램이 초안과 실제 세션 저장소에 표시된 장비 창
  2. 호스트 창에서 통신을 수립하고 Process Programs → Traffic 탭을 엽니다. Kind를 Request, PPID 형식을 Ascii로 두고 DEMO-PP를 입력한 뒤 Submit을 누릅니다.

  3. 장비 창의 Traffic 탭에서 받은 요청을 고르고 Respond를 누릅니다.

  4. 호스트 창의 결과 표에 Kind Request, State Completed인 행이 생깁니다. S7F6에는 응답 코드가 없으므로 ACK는 -입니다.

    호스트가 DEMO-PP를 요청해 결과가 Completed로 표시된 Process Programs Traffic 탭호스트가 DEMO-PP를 요청해 결과가 Completed로 표시된 Process Programs Traffic 탭

Kind를 Directory나 Delete로 바꾸면 S7F19와 S7F17을 보냅니다. 이 두 종류는 호스트 창에서만 고를 수 있습니다. 정형 프로그램은 Formatted transfer, Formatted verification, Verification negotiation, Formatted query, Selection, Dry run, Execution, Capabilities 탭에서 같은 흐름을 실행합니다.

증상 원인 해결
요청이 InvalidState로 실패 GEM 통신 전에 보냈거나, 프로그램 저장소를 구성하지 않았습니다. 통신 수립 뒤에 보내고, 시작 전에 ConfigurePrograms를 호출합니다.
장비 C# 예제에서 transfer가 COMMAND ERROR ... InvalidState S7F23은 호스트만 보낼 수 있습니다. 정형 요청 명령은 호스트 터미널에서 입력합니다.
S7F2에 PPGNT 1~5 이미 있는 PPID, 저장 공간 부족, 허가 정책 위반 등입니다. 저장소 한도(GemProgramOptions)와 기존 PPID를 확인합니다.
S7F4에 ACKC7 1 허가 정책이 Required인데 S7F1 허가 없이 S7F3을 보냈습니다. S7F1로 먼저 허가받거나 정책을 Optional로 둡니다.
S7F4에 ACKC7 2 S7F1에서 선언한 길이와 실제 PPBODY 길이가 다릅니다. 허가 요청의 길이를 실제 본문 크기로 맞춥니다.
S7F6의 PPID와 PPBODY가 비어 있음 저장소에 없는 PPID를 요청했습니다. S7F19로 PPID 목록을 먼저 확인합니다.
S7F18에 ACKC7 3 삭제 목록의 PPID 수가 장비의 GemProgramOptions.MaxRequestIds보다 많습니다. 장비는 아무것도 지우지 않습니다. 목록을 나눠 여러 번 보냅니다.
S7F18에 ACKC7 4 S7F17의 삭제 목록에 저장되지 않은 PPID나 저장소가 받을 수 없을 만큼 긴 PPID가 있습니다. 장비는 PPID를 찾을 수 없다는 ACKC7 4로 답하고 아무것도 지우지 않습니다. 삭제 목록을 S7F19 결과로 확인합니다.
호스트의 프로그램 요청이 InvalidArgument로 실패 PPID가 비어 있거나 ASCII·바이너리 아이템이 아닙니다. 비어 있지 않은 ASCII나 바이너리 PPID를 씁니다.
호스트의 프로그램 요청이 LimitExceeded로 실패 PPID가 120바이트보다 길거나, PPID 수나 메시지 크기가 한도(MaxRequestIds, 대기 중 요청 한도, 아이템 한도)를 넘었습니다. PPID와 요청을 줄이거나 한도를 늘립니다.
ConfigurePrograms가 InvalidArgument로 실패 저장 파일 경로에 파일 이름이 없습니다. 폴더 경로만 넘긴 경우입니다. 파일 이름까지 포함한 경로를 넘깁니다.
검증 협상의 NegotiationTerminal이나 VerificationTerminal이 0인 채로 끝남 호스트가 보낸 S7F30이나 S7F28의 형식이 잘못되었습니다. 응답이 거래를 닫으므로 협상은 그 자리에서 끝나고 차지하던 슬롯도 풀립니다. 호스트의 응답 형식을 확인하고 새 협상을 시작합니다.
RespondVerification이나 RespondVerificationRequest가 StaleToken으로 실패 이미 S7F30이나 S7F32를 보낸 요청에 다른 코드로 다시 응답했습니다. 보낸 응답은 바꿀 수 없습니다. 다시 시도할 때는 처음과 같은 코드를 씁니다.
RespondProgram이 ProgramUnconfirmed 파일 저장을 확인하지 못했습니다. 4단계의 ResyncPrograms → ConfirmProgramReply → 같은 인수로 RespondProgram 순서를 따릅니다.
Backpressure 보관된 전송 이벤트를 먼저 읽어야 하거나, 전송 큐가 응답을 받지 못합니다. 이벤트를 계속 읽고, 같은 모드와 결정으로 다시 호출합니다.
예제가 Usage: ...를 출력하고 종료 코드 2 네이티브 라이브러리 경로가 절대 경로가 아닙니다. $PDCOMM_NATIVE처럼 절대 경로를 넘깁니다.
  • 대화형 예제는 양쪽 터미널에 quit를 입력해 끝냅니다.
  • PDComm.GemProgramsDemo는 메모리 저장소만 쓰므로 남는 파일이 없습니다.
  • 시뮬레이터에서는 양쪽 창에서 Stop을 누릅니다. 장비 세션을 멈춘 뒤에도 남는 저장소를 지우려면 End session을 누릅니다.