공정 프로그램
공정 프로그램(process program)은 장비가 제품을 처리하는 방법을 담은 데이터입니다. 현장에서는 흔히 레시피(recipe)라고 부릅니다. 각 프로그램은 PPID(process program ID)로 구별하고, 내용은 PPBODY에 담습니다. GEM은 스트림 7(S7) 메시지로 호스트와 장비가 프로그램을 주고받는 방법을 정합니다.
S7에는 두 종류가 있습니다.
- 비정형(unformatted) 프로그램: PPBODY를 해석하지 않는 하나의 값으로 주고받습니다. S7F1
S7F6, 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가 여러 번 나오면 한 번만 지웁니다. 거부한 요청은 아무것도 지우지 않습니다.
1. 예제 빌드
섹션 제목: “1. 예제 빌드”dotnet build "$Examples\dotnet\PDComm.GemProgramsDemo\PDComm.GemProgramsDemo.csproj" -c Release "-p:PDCommSdk=$Sdk" --artifacts-path "$Root\build\managed"dotnet build "$PDCOMM_EXAMPLES/dotnet/PDComm.GemProgramsDemo/PDComm.GemProgramsDemo.csproj" -c Release "-p:PDCommSdk=$PDCOMM_SDK" --artifacts-path "$PDCOMM_ROOT/build/managed"dotnet build "$PDCOMM_EXAMPLES/dotnet/PDComm.GemProgramsDemo/PDComm.GemProgramsDemo.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.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># 터미널 A: 장비dotnet "$PDCOMM_ROOT/build/managed/bin/PDComm.GemProgramsDemo/release/PDComm.GemProgramsDemo.dll" "$PDCOMM_NATIVE" equipment passive 127.0.0.1:0# 터미널 B: 호스트dotnet "$PDCOMM_ROOT/build/managed/bin/PDComm.GemProgramsDemo/release/PDComm.GemProgramsDemo.dll" "$PDCOMM_NATIVE" host active 127.0.0.1:<port># 터미널 A: 장비dotnet "$PDCOMM_ROOT/build/managed/bin/PDComm.GemProgramsDemo/release/PDComm.GemProgramsDemo.dll" "$PDCOMM_NATIVE" equipment passive 127.0.0.1:0# 터미널 B: 호스트dotnet "$PDCOMM_ROOT/build/managed/bin/PDComm.GemProgramsDemo/release/PDComm.GemProgramsDemo.dll" "$PDCOMM_NATIVE" host active 127.0.0.1:<port>기대 결과: 장비는 LISTEN 127.0.0.1:<port>를 먼저 출력합니다. 교환이 끝나면 양쪽이 다음 줄을 출력하고 종료 코드 0으로 끝납니다.
PASS host active DOTNET PROGRAMS PEERPASS equipment passive DOTNET PROGRAMS PEER3. 시나리오 따라 읽기
섹션 제목: “3. 시나리오 따라 읽기”예제는 같은 코드로 호스트와 장비를 모두 실행합니다. 이 예제는 작업자(worker)를 쓰지 않고 끝점(GemHost, GemEquipment)을 직접 Poll하는 방식입니다. 시작하기 전에 양쪽이 저장소를 메모리로만 구성하고(ConfigurePrograms에 경로를 주지 않음), 프로그램 하나씩을 넣습니다(SeedProgram).
- 장비: PPID
BASE, PPBODY ASCII"BASE" - 호스트: PPID
HOST, PPBODY Binary48(문자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 전체 코드
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, };}4. 저장 파일과 내구성
섹션 제목: “4. 저장 파일과 내구성”ConfigurePrograms에 파일 경로를 주면 저장소가 그 파일에 저장됩니다. 프로그램을 바꿀 때마다 쓰기 결과의 내구성(GemProgramDurability)을 확인합니다.
| 내구성 | 의미 |
|---|---|
None |
이 작업에 저장 변경이 없습니다. 거부했거나 읽기 요청입니다. |
Volatile |
저장 파일이 없어 메모리에만 반영했습니다. |
Synced |
파일에 쓰고 상위 폴더까지 동기화했습니다. |
Unconfirmed |
변경은 반영했지만 저장을 확인하지 못했습니다. |
파일 저장을 확인하지 못하면 RespondProgram은 응답을 보내지 않고 PdcommStatus.ProgramUnconfirmed 상태로 끝납니다. 변경은 이미 저장소에 있습니다. 이때는 다음 순서로 처리합니다.
- 필요하면
GemEquipment.ResyncPrograms로 저장소와 파일을 다시 맞춥니다. GemEquipment.ConfirmProgramReply로 보류한 응답을 확인합니다.- 같은 인수로
RespondProgram을 다시 호출해 저장해 둔 응답을 보냅니다.
전송 실패(예: Backpressure)로 응답을 못 보냈을 때도 같은 모드와 결정으로 다시 호출합니다. 작업을 처음부터 다시 하지 않습니다.
Qt 예제 묶음의 programs.cpp도 이 순서를 따릅니다. 이 예제는 장비 저장소를 명령줄의 SNAPSHOT 파일에 저장하고(configurePrograms(options(false), path)), GEM 끝점을 QThread 작업 스레드에서 실행하며, 응답 검사를 Qt 메인 스레드로 넘깁니다. 다만 PDComm 내부 시험과 시나리오 정의를 공유하므로 SDK만으로는 빌드되지 않고, 이 문서에도 싣지 않습니다. 빌드하고 실행할 수 있는 Qt 예제는 원격 명령의 commands.cpp와 보고서와 이벤트의 reports.cpp를 봅니다.
5. 정형 프로그램 실행
섹션 제목: “5. 정형 프로그램 실행”정형 프로그램 메시지는 방향이 정해져 있습니다.
| 메시지 | 방향 | 내용 |
|---|---|---|
| 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# 터미널 A: 장비dotnet "$PDCOMM_ROOT/build/managed/bin/PDComm.Tutorial/release/PDComm.Tutorial.dll" "$PDCOMM_NATIVE" equipment passive 127.0.0.1:5000# 터미널 B: 호스트dotnet "$PDCOMM_ROOT/build/managed/bin/PDComm.Tutorial/release/PDComm.Tutorial.dll" "$PDCOMM_NATIVE" host active 127.0.0.1:5000# 터미널 A: 장비dotnet "$PDCOMM_ROOT/build/managed/bin/PDComm.Tutorial/release/PDComm.Tutorial.dll" "$PDCOMM_NATIVE" equipment passive 127.0.0.1:5000# 터미널 B: 호스트dotnet "$PDCOMM_ROOT/build/managed/bin/PDComm.Tutorial/release/PDComm.Tutorial.dll" "$PDCOMM_NATIVE" host active 127.0.0.1:5000호스트 터미널에 명령을 한 줄씩 입력하고 응답을 기다립니다. 먼저 establish로 통신을 수립합니다.
establishRESULT kind=2 request=1 outcome=2 status=CommunicationEstablishedSECS-II 0102210100010241065044436F6D6D4105302E312E30capabilityRESULT kind=2 request=39 outcome=65 status=FormattedCapabilityResultSECS-II 010541065044436F6D6D4105302E312E30A50104B104000010000101010B410352554E410352756E250101650100A900A900A900A9020000A900A9000100transferRESULT kind=2 request=36 outcome=59 status=FormattedTransferAckSECS-II 210100queryRESULT kind=2 request=37 outcome=61 status=FormattedQueryResultSECS-II 0104410750502D44454D4F41065044436F6D6D4105302E312E3001010102410352554E0100verify-requestRESULT kind=2 request=38 outcome=63 status=FormattedVerificationRequestAckSECS-II 210100장비 터미널에는 받은 요청이 kind=1(primary)로 나타납니다. 장비 예제는 받은 정형 요청에 바로 응답합니다(RespondCapabilityQuery, RespondTransferAndStore, RespondQuery, RespondVerificationRequest).
RESULT kind=1 request=0 outcome=2 status=CommunicationEstablishedSECS-II 0100RESULT kind=1 request=39 outcome=64 status=FormattedCapabilityReceivedRESULT kind=1 request=36 outcome=58 status=FormattedTransferReceivedSECS-II 0104410750502D44454D4F41065044436F6D6D4105302E312E3001010102410352554E0100RESULT kind=1 request=37 outcome=60 status=FormattedQueryReceivedSECS-II 410750502D44454D4FRESULT kind=1 request=38 outcome=62 status=FormattedVerificationRequestReceivedSECS-II 0104410750502D44454D4F41065044436F6D6D4105302E312E3001010102410352554E0100이제 장비 터미널에 장비 명령을 입력합니다.
verify-reportRESULT kind=2 request=35 outcome=57 status=FormattedVerificationReportAcknegotiateRESULT kind=2 request=40 outcome=67 status=FormattedVerificationGrantReceivedSECS-II 210100RESULT kind=2 request=35 outcome=68 status=FormattedVerificationNegotiationAckstoreSTORE entries=1dryrunDRYRUN outcome=Pass issues=0executeDRYRUN outcome=Pass issues=0APPLICATION DEMO: acknowledge RUN without controlling equipmentEXECUTION state=Completed호스트 터미널에는 장비가 보낸 검증 보고와 문의가 나타납니다.
RESULT kind=1 request=35 outcome=56 status=FormattedVerificationReportReceivedSECS-II 0102410750502D44454D4F01010103B10400000001B10400000001410A64656D6F206973737565RESULT kind=1 request=40 outcome=66 status=FormattedVerificationInquireReceivedSECS-II B1040000000DRESULT kind=1 request=35 outcome=56 status=FormattedVerificationReportReceivedSECS-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 전체 코드
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; }6. 시뮬레이터 GUI에서 확인
섹션 제목: “6. 시뮬레이터 GUI에서 확인”시뮬레이터 GUI 가이드의 창 두 개 구성을 그대로 씁니다.
-
장비 창의 Process Programs → Catalog 탭에서 Load example을 누릅니다. Equipment program draft에 PPID
DEMO-PP, PPBODYDEMO항목이 생깁니다. Use on next start를 누른 뒤 장비 세션을 시작합니다. 시작하면 Actual session store에 같은 항목이 보입니다.

-
호스트 창에서 통신을 수립하고 Process Programs → Traffic 탭을 엽니다. Kind를 Request, PPID 형식을 Ascii로 두고
DEMO-PP를 입력한 뒤 Submit을 누릅니다. -
장비 창의 Traffic 탭에서 받은 요청을 고르고 Respond를 누릅니다.
-
호스트 창의 결과 표에 Kind
Request, StateCompleted인 행이 생깁니다. S7F6에는 응답 코드가 없으므로 ACK는-입니다.

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을 누릅니다.
다음 단계
섹션 제목: “다음 단계”- 스풀링에서 통신이 끊긴 동안 메시지를 보관했다가 보내는 방법을 실습합니다.
- API 세부 사항은 SECS/GEM API 레퍼런스를 봅니다.