보고서와 이벤트
호스트(host)는 이벤트 보고서(event report)로 “이 이벤트가 일어나면 이 값들을 보내라”고 장비(equipment)에 미리 알려 둡니다. 이 튜토리얼에서는 호스트가 보고서를 정의·연결·활성화하고, 장비가 보낸 S6F11을 받아 S6F12로 확인하는 전체 흐름을 직접 실행합니다.
S2F33(보고서 정의),S2F35(이벤트 연결),S2F37(이벤트 활성화)의 순서와 응답 코드를 이해합니다.- 장비가
S6F11을 보내는 조건과 호스트가 보고서 값을 읽는 방법을 익힙니다. - 같은 흐름을 .NET 워커(worker)와 Qt 스레드 모델로 구현한 예제를 읽습니다.
준비물
섹션 제목: “준비물”- 환경 준비를 마친 SDK와 셸입니다. 이 페이지는 거기서 설정한
PDCOMM_ROOT,PDCOMM_SDK,PDCOMM_NATIVE,PDCOMM_EXAMPLES(Windows는$Root,$Sdk,$Native,$Examples)를 그대로 씁니다. - .NET 10 SDK입니다. Qt 예제를 빌드하려면 Qt 6(Core 모듈), CMake 3.20 이상, Ninja 같은 빌드 도구가 추가로 필요합니다.
- 첫 통신에서 통신 수립(
S1F13)을 해 본 경험이 있으면 좋습니다.
예제가 하는 일
섹션 제목: “예제가 하는 일”PDComm.GemReportsDemo는 한 프로그램에 호스트와 장비 역할이 모두 들어 있습니다. 장비 쪽은 상태 변수(SVID) 두 개와 수집 이벤트(CEID) 두 개를 가지고 시작합니다.
| 장비 데이터 | 값 |
|---|---|
SVID 1 Temperature |
F4 21.5 (첫 이벤트 보고 직후 32.0으로 바뀝니다) |
SVID 2 Lot |
ASCII LOT-001 |
| CEID | 100, 200 |
보고서 바인딩(binding)을 따로 설정하지 않았으므로 보고서의 VID는 같은 번호의 상태 변수를 가리킵니다. 호스트는 통신을 수립한 뒤 다음 9단계를 차례로 보냅니다.
Host Equipment 0 S2F33 DATAID 1 RPTID 10=[VID 2,1,2] RPTID 20=[VID 1] -> S2F34 DRACK 0 1 S2F35 DATAID 2 CEID 100->[10,20] CEID 200->[20] -> S2F36 LRACK 0 2 S2F37 enable all events -> S2F38 ERACK 0 <- S6F11 DATAID 41 CEID 100 RPT 10=[LOT-001, 21.5, LOT-001] RPT 20=[21.5] S6F12 ACKC6 0 -> 3 S2F37 disable CEID 100 -> ERACK 0 4 S2F33 DATAID 3 RPTID 20=[] (delete report 20) -> DRACK 0 5 S2F37 enable CEID 100 -> ERACK 0 <- S6F11 DATAID 42 CEID 100 RPT 10=[LOT-001, 32.0, LOT-001] S6F12 ACKC6 7 -> 6 S2F33 DATAID 4 (empty list: delete all reports) -> DRACK 0 7 S2F37 enable CEID 200 (no report linked any more) -> ERACK 0 8 S2F35 DATAID 5 CEID 200->[10] (RPTID 10 is gone) -> LRACK 5- 4단계처럼 VID 목록이 빈 보고서를 정의하면 그 보고서와 연결이 지워집니다. 6단계처럼 목록 자체가 비어 있으면 모든 보고서가 지워집니다.
- 보고서를 정의·삭제하거나 연결을 바꿔도 이벤트의 활성화 상태는 바뀌지 않습니다. 활성화 상태는
S2F37로만 바꿉니다. - 장비는 이벤트 활성화 응답(
S2F38)을 보낸 뒤 CEID 100이 켜져 있으면S6F11을 보냅니다. 3단계에서 꺼 둔 동안에는 보내지 않습니다. - 5단계의
S6F12는 ACKC6 7을 보냅니다. ACKC6 0은 수락, 1–63은 거부입니다. 예제는 0이 아닌 값도 장비까지 그대로 전달되는지 확인합니다. - 7단계는 연결된 보고서가 하나도 없는 CEID 200도 켤 수 있음을 보여 줍니다. ERACK 1은 장비에 없는 CEID를 지정했을 때만 돌아옵니다. 켜진 이벤트에 연결된 보고서가 없으면 장비는 보고서 목록이 빈
S6F11을 보냅니다. - 8단계는 거부 응답을 보여 줍니다. 없는 RPTID를 연결하면 장비는 LRACK 5를 돌려줍니다.
1. 예제를 빌드합니다
섹션 제목: “1. 예제를 빌드합니다”dotnet build "$Examples\dotnet\PDComm.GemReportsDemo\PDComm.GemReportsDemo.csproj" -c Release "-p:PDCommSdk=$Sdk" --artifacts-path "$Root\build\managed"dotnet build "$PDCOMM_EXAMPLES/dotnet/PDComm.GemReportsDemo/PDComm.GemReportsDemo.csproj" -c Release "-p:PDCommSdk=$PDCOMM_SDK" --artifacts-path "$PDCOMM_ROOT/build/managed"dotnet build "$PDCOMM_EXAMPLES/dotnet/PDComm.GemReportsDemo/PDComm.GemReportsDemo.csproj" -c Release "-p:PDCommSdk=$PDCOMM_SDK" --artifacts-path "$PDCOMM_ROOT/build/managed"-p:PDCommSdk를 주면 예제는 SDK의 managed/PDComm.dll을 참조합니다. 빌드가 끝나면 다음 줄이 보입니다.
PDComm.GemReportsDemo -> <PDCOMM_ROOT>/build/managed/bin/PDComm.GemReportsDemo/release/PDComm.GemReportsDemo.dll
Build succeeded. 0 Warning(s) 0 Error(s)2. 장비를 실행합니다
섹션 제목: “2. 장비를 실행합니다”첫 번째 터미널에서 장비를 Passive 모드로 시작합니다. 포트 0을 주면 운영체제가 빈 포트를 고릅니다.
dotnet "$Root\build\managed\bin\PDComm.GemReportsDemo\release\PDComm.GemReportsDemo.dll" $Native equipment passive 127.0.0.1:0dotnet "$PDCOMM_ROOT/build/managed/bin/PDComm.GemReportsDemo/release/PDComm.GemReportsDemo.dll" "$PDCOMM_NATIVE" equipment passive 127.0.0.1:0dotnet "$PDCOMM_ROOT/build/managed/bin/PDComm.GemReportsDemo/release/PDComm.GemReportsDemo.dll" "$PDCOMM_NATIVE" equipment passive 127.0.0.1:0장비가 듣는 주소를 출력하고 호스트를 기다립니다. 포트 번호는 실행할 때마다 다릅니다.
LISTEN 127.0.0.1:544753. 호스트를 실행합니다
섹션 제목: “3. 호스트를 실행합니다”두 번째 터미널에서 장비가 출력한 주소로 호스트를 Active 모드로 실행합니다.
dotnet "$Root\build\managed\bin\PDComm.GemReportsDemo\release\PDComm.GemReportsDemo.dll" $Native host active 127.0.0.1:54475dotnet "$PDCOMM_ROOT/build/managed/bin/PDComm.GemReportsDemo/release/PDComm.GemReportsDemo.dll" "$PDCOMM_NATIVE" host active 127.0.0.1:54475dotnet "$PDCOMM_ROOT/build/managed/bin/PDComm.GemReportsDemo/release/PDComm.GemReportsDemo.dll" "$PDCOMM_NATIVE" host active 127.0.0.1:544759단계가 1초 안에 끝나고 양쪽이 종료 코드 0으로 끝납니다.
PASS host active: managed GEM reports worker completed scenario 9 and shut down장비 터미널에는 다음 줄이 이어집니다.
PASS equipment passive: managed GEM reports worker completed scenario 9 and shut down두 프로그램은 각 단계의 응답 코드, 이벤트 값, 토큰(token)을 스스로 검사합니다. 하나라도 다르면 InvalidOperationException을 던지고 0이 아닌 코드로 끝납니다.
4. 코드를 읽습니다
섹션 제목: “4. 코드를 읽습니다”장비 쪽
섹션 제목: “장비 쪽”StartEquipmentAsync는 상태 변수와 GemReportConfiguration를 넘겨 장비 워커를 시작합니다. GemReportConfiguration의 두 번째 인수 { 100, 200 }이 장비가 제공하는 CEID 목록입니다. 보고서와 연결은 호스트가 원격으로 만듭니다.
RunEquipmentAsync는 이벤트 스트림을 읽으며 다음 일을 합니다.
- 호스트의
S2F33/S2F35/S2F37은 SDK가 자동으로 응답합니다. 애플리케이션은 결과의Outcome(DefineReportsAck–EnableEventsAck)과Acknowledgement만 확인합니다. - 2단계와 5단계의
EnableEventsAck가 0이고 CEID 100이 켜져 있으면PublishEventAsync로S6F11을 보냅니다. 첫 보고 뒤에는UpdateStatusAsync로 온도를 32.0으로 바꿉니다. 이미 보낸 보고서에는 영향이 없습니다. 보고서 값은 보내는 순간의 스냅숏(snapshot)입니다. - 호스트의
S6F12는Response결과(EventReport요청,EventAck결과)로 돌아옵니다. 예제는 보낼 때 받은 토큰과 응답의 토큰을 비교합니다.
호스트 쪽
섹션 제목: “호스트 쪽”SendStepAsync가 9단계 요청을 만듭니다.
DefineReportsAsync: DATAID와GemReportSpec목록으로S2F33을 보냅니다.LinkReportsAsync:GemLinkSpec목록으로S2F35를 보냅니다.EnableEventsAsync: CEID 목록을 비우면 모든 이벤트를 대상으로 합니다.
RunHostAsync는 Outcome이 EventReceived인 결과를 받으면 CheckEvent로 값을 확인하고 AcknowledgeEventAsync로 S6F12를 보냅니다. SDK는 S6F11에 자동으로 응답하지 않습니다. 호스트 애플리케이션이 보고서를 처리한 뒤 직접 응답합니다.
S6F11의 값에는 VID가 들어 있지 않습니다. GetEventReport로 보고서 ID와 값 개수를, GetEventValue로 값을 순서대로 꺼내고, 위치는 호스트가 정의한 보고서와 맞춰 해석합니다. 예제의 RPTID 10은 VID 2, 1, 2 순서이므로 값도 LOT-001, 21.5, LOT-001 순서입니다.
전체 코드: PDComm.GemReportsDemo/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.GemReportsDemo <absolute-native-library-path> <host|equipment> <active|passive> <numeric-address>"); return 2;}
PdcommRuntime.Initialize(args[0]);string role = args[1];HsmsMode mode = args[2] == "active" ? HsmsMode.Active : HsmsMode.Passive;SecsLimits itemLimits = new(4096, 4096, 256, 16);HsmsOptions transportOptions = DemoOptions.Transport(mode, itemLimits);HsmsWorkerOptions workerOptions = DemoOptions.Worker();using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(20));await using GemWorker worker = role == "host" ? await GemHostWorker.StartAsync( args[3], transportOptions, DemoOptions.Host(itemLimits), workerOptions, deadline.Token) : await StartEquipmentAsync(args[3], transportOptions, itemLimits, workerOptions, deadline.Token);
if (mode == HsmsMode.Passive){ string address = worker.LocalAddress ?? throw new InvalidOperationException("The passive endpoint did not publish its listening address."); Console.WriteLine($"LISTEN {address}"); Console.Out.Flush();}
await using IAsyncEnumerator<GemEvent> events = worker.ReadEventsAsync(deadline.Token).GetAsyncEnumerator();if (worker is GemHostWorker host) await RunHostAsync(host, events, deadline.Token);else if (worker is GemEquipmentWorker equipment) await RunEquipmentAsync(equipment, events, itemLimits, deadline.Token);
while (await events.MoveNextAsync()) events.Current.Dispose();await worker.Completion.WaitAsync(deadline.Token);Console.WriteLine($"PASS {role} {args[2]}: managed GEM reports worker completed scenario 9 and shut down");return 0;
static async Task<GemEquipmentWorker> StartEquipmentAsync( string address, HsmsOptions transport, SecsLimits limits, HsmsWorkerOptions worker, CancellationToken cancellationToken){ using SecsItem temperature = SecsItem.FromF4([21.5f], limits); using SecsItem lot = SecsItem.FromAscii("LOT-001", limits); GemStatusVariable[] variables = [ new(1, "Temperature", "C", temperature), new(2, "Lot", "", lot), ]; var reports = new GemReportConfiguration( DemoOptions.Reports(), new uint[] { 100, 200 }); return await GemEquipmentWorker.StartAsync( address, transport, DemoOptions.Equipment(limits), worker, variables, reports, cancellationToken);}
static async Task RunHostAsync( GemHostWorker host, IAsyncEnumerator<GemEvent> events, CancellationToken cancellationToken){ bool began = false; bool established = false; int step = 0; int received = 0; HsmsToken pending = default; while (step < 9) { if (!began && (await host.GetTransportStateAsync(cancellationToken)).State == HsmsConnectionState.Selected) { pending = await host.BeginCommunicationAsync(cancellationToken); began = true; }
if (!await events.MoveNextAsync()) throw new InvalidOperationException("The GEM event stream ended before the reports scenario completed."); GemEvent current = events.Current; bool disposed = false; try { _ = ValidateEvent(current); GemResult? result = current.Result; if (result is null) continue; GemResultInfo info = result.GetInfo(); if (info.Kind == GemResultKind.Response) { if (info.Token != pending) throw new InvalidOperationException("The GEM response token did not match the current request."); if (!established) { if (info.RequestKind != GemRequestKind.Establish || info.Outcome != GemOutcome.CommunicationEstablished) throw new InvalidOperationException("GEM communication establishment failed."); established = true; pending = await SendStepAsync(host, step, cancellationToken); } else { CheckResponse(info, step); if (step is not (2 or 5)) { step++; if (step < 9) pending = await SendStepAsync(host, step, cancellationToken); } } } else if (info.Outcome == GemOutcome.EventReceived) { int expectedStep = received == 0 ? 2 : 5; if (step != expectedStep || info.Kind != GemResultKind.Primary || info.ReplySent || info.Acknowledgement is not null) throw new InvalidOperationException("The received event report state is incorrect."); CheckEvent(result, received); Task acknowledgement = host.AcknowledgeEventAsync( result, received == 0 ? (byte)0 : (byte)7, cancellationToken); current.Dispose(); disposed = true; await acknowledgement; received++; step++; pending = await SendStepAsync(host, step, cancellationToken); } } finally { if (!disposed) current.Dispose(); } }
if (received != 2) throw new InvalidOperationException("The host did not receive both event reports."); await host.StopAsync(cancellationToken);}
static async Task RunEquipmentAsync( GemEquipmentWorker equipment, IAsyncEnumerator<GemEvent> events, SecsLimits limits, CancellationToken cancellationToken){ int configured = 0; int published = 0; int acknowledged = 0; bool communicated = false; HsmsToken[] tokens = new HsmsToken[2]; while (true) { if (!await events.MoveNextAsync()) throw new InvalidOperationException("The GEM event stream ended before the reports scenario completed."); using GemEvent current = events.Current; HsmsEventInfo transportEvent = ValidateEvent(current); GemResult? result = current.Result; if (result is not null) { GemResultInfo info = result.GetInfo(); if (info.State.Communication == GemCommunicationState.Communicating) communicated = true; if (info.Kind == GemResultKind.Primary && info.Outcome is >= GemOutcome.DefineReportsAck and <= GemOutcome.EnableEventsAck) { configured++; // Publish CEID 100 after the enable steps 2 (all events) and 5 (CEID 100) only. if (info.Outcome == GemOutcome.EnableEventsAck && info.Acknowledgement == 0 && configured is 3 or 6 && (await equipment.GetEventAtAsync(0, cancellationToken)).Enabled) { if (published >= 2) throw new InvalidOperationException("Too many reports were published."); tokens[published] = await equipment.PublishEventAsync( (uint)(41 + published), 100, cancellationToken); published++; if (published == 1) { using SecsItem updated = SecsItem.FromF4([32.0f], limits); await equipment.UpdateStatusAsync(1, updated, cancellationToken); } } } else if (info.Kind == GemResultKind.Response) { if (acknowledged >= 2 || info.RequestKind != GemRequestKind.EventReport || info.Outcome != GemOutcome.EventAck || info.Acknowledgement != (acknowledged == 0 ? 0 : 7) || info.Token != tokens[acknowledged]) throw new InvalidOperationException("The event acknowledgement response is incorrect."); acknowledged++; } }
if (communicated && transportEvent.Kind == HsmsEventKind.StateChanged && transportEvent.FromState == HsmsConnectionState.Selected && transportEvent.ToState != HsmsConnectionState.Selected) { GemReportState state = await equipment.GetReportStateAsync(cancellationToken); if (configured != 9 || published != 2 || acknowledged != 2 || state != new GemReportState(2, 0, 0, 0)) throw new InvalidOperationException("The final equipment report state is incorrect."); await equipment.StopAsync(cancellationToken); return; } }}
static async Task<HsmsToken> SendStepAsync( GemHostWorker host, int step, CancellationToken cancellationToken) => step switch{ 0 => await host.DefineReportsAsync(1, new GemReportSpec[] { new(10, new uint[] { 2, 1, 2 }), new(20, new uint[] { 1 }) }, cancellationToken), 1 => await host.LinkReportsAsync(2, new GemLinkSpec[] { new(100, new uint[] { 10, 20 }), new(200, new uint[] { 20 }) }, cancellationToken), 2 => await host.EnableEventsAsync(true, Array.Empty<uint>(), cancellationToken), 3 => await host.EnableEventsAsync(false, new uint[] { 100 }, cancellationToken), 4 => await host.DefineReportsAsync(3, new GemReportSpec[] { new(20, ReadOnlyMemory<uint>.Empty) }, cancellationToken), 5 => await host.EnableEventsAsync(true, new uint[] { 100 }, cancellationToken), 6 => await host.DefineReportsAsync(4, Array.Empty<GemReportSpec>(), cancellationToken), 7 => await host.EnableEventsAsync(true, new uint[] { 200 }, cancellationToken), 8 => await host.LinkReportsAsync(5, new GemLinkSpec[] { new(200, new uint[] { 10 }) }, cancellationToken), _ => throw new ArgumentOutOfRangeException(nameof(step)),};
static void CheckResponse(GemResultInfo info, int step){ GemRequestKind kind = step switch { 0 or 4 or 6 => GemRequestKind.DefineReports, 1 or 8 => GemRequestKind.LinkReports, _ => GemRequestKind.EnableEvents, }; GemOutcome outcome = step switch { 0 or 4 or 6 => GemOutcome.DefineReportsAck, 1 or 8 => GemOutcome.LinkReportsAck, _ => GemOutcome.EnableEventsAck, }; // Step 7 enables CEID 200 after S2F33 removed every report: the CEID exists, so ERACK is 0. // Step 8 links an RPTID that no longer exists, so LRACK is 5. byte acknowledgement = step == 8 ? (byte)5 : (byte)0; if (info.Kind != GemResultKind.Response || info.RequestKind != kind || info.Outcome != outcome || info.Acknowledgement != acknowledgement || info.ReplySent) throw new InvalidOperationException($"Unexpected reports response at scenario step {step}.");}
static void CheckEvent(GemResult result, int ordinal){ GemEventInfo info = result.GetEventInfo(); nuint expectedReports = ordinal == 0 ? 2u : 1u; if (info != new GemEventInfo((uint)(41 + ordinal), 100, expectedReports, false)) throw new InvalidOperationException("The event report metadata is incorrect."); for (nuint reportIndex = 0; reportIndex < info.ReportCount; reportIndex++) { GemEventReport report = result.GetEventReport(reportIndex); uint expectedId = reportIndex == 0 ? 10u : 20u; nuint expectedValues = reportIndex == 0 ? 3u : 1u; if (report != new GemEventReport(expectedId, expectedValues)) throw new InvalidOperationException("The event report definition is incorrect."); for (nuint valueIndex = 0; valueIndex < report.ValueCount; valueIndex++) { using SecsItem value = result.GetEventValue(reportIndex, valueIndex); if (reportIndex == 0 && valueIndex != 1) { if (value.GetAscii() != "LOT-001") throw new InvalidOperationException("The event lot value is incorrect."); } else { float[] values = value.GetF4(); float expected = ordinal == 0 ? 21.5f : 32.0f; if (values.Length != 1 || BitConverter.SingleToInt32Bits(values[0]) != BitConverter.SingleToInt32Bits(expected)) throw new InvalidOperationException("The event temperature value is incorrect."); } } }}
static HsmsEventInfo ValidateEvent(GemEvent current){ if (current.ProcessingStatus != PdcommStatus.Ok) throw new PdcommException(current.ProcessingStatus); HsmsEventInfo raw = current.TransportEvent.GetInfo(); if (raw.Kind == HsmsEventKind.Failed) throw new InvalidOperationException($"HSMS transaction failed with reason {raw.Code}."); if (raw.Kind == HsmsEventKind.ProtocolError) throw new InvalidOperationException($"HSMS protocol error {raw.Code}, detail {raw.Detail}."); if (raw.Kind == HsmsEventKind.TransportError) throw new InvalidOperationException($"HSMS transport error {raw.Code}, detail {raw.Detail}."); return raw;}
static class DemoOptions{ internal static HsmsOptions Transport(HsmsMode mode, SecsLimits limits) => 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 = 32768, MaxWriteFrames = 64, MaxWriteBytes = 16384, MaxIoChunk = 3, MaxApplicationEvents = 128, MaxApplicationBytes = 32768, PollBudget = 32, ItemLimits = limits, };
internal static HsmsWorkerOptions Worker() => new() { CommandCapacity = 64, MaxCommandBytes = 65536, EventCapacity = 128, MaxEventBytes = 65536, PollIntervalMilliseconds = 1, ShutdownTimeoutMilliseconds = 5000, };
internal static GemHostOptions Host(SecsLimits limits) => new() { MaxPendingIds = 64, ItemLimits = limits, };
internal static GemEquipmentOptions Equipment(SecsLimits limits) => new() { Model = "PDComm", SoftwareRevision = "0.1.0", InitialControl = GemControlState.HostOffline, OnlineMode = GemOnlineMode.Remote, MaxVariables = 8, MaxMetadataBytes = 1024, MaxValueBytes = 4096, ItemLimits = limits, };
internal static GemReportOptions Reports() => new() { MaxEvents = 4, MaxReports = 4, MaxVariablesPerReport = 8, MaxReportsPerEvent = 4, MaxTotalVariables = 16, MaxTotalLinks = 16, };}C API는 pdcomm_gem_host_define_reports, pdcomm_gem_host_link_reports, pdcomm_gem_host_enable_events, pdcomm_gem_equipment_publish_event, pdcomm_gem_host_acknowledge_event가 같은 역할을 합니다.
5. Qt로 같은 흐름을 실행합니다
섹션 제목: “5. Qt로 같은 흐름을 실행합니다”examples/qt/reports.cpp는 같은 9단계를 C++ 래퍼로 구현합니다. 끝점(endpoint)은 QThread 작업 스레드에서만 다루고, 받은 이벤트는 queued_handoff.hpp의 invokeQueued로 UI 스레드에 넘겨 검사합니다. UI 스레드가 고른 ACKC6를 작업 스레드가 받아 GemHost::acknowledgeEvent를 호출합니다. GUI 애플리케이션에서 PDComm 끝점을 다루는 기본 구조입니다.
빌드 파일을 만듭니다
섹션 제목: “빌드 파일을 만듭니다”빈 폴더에 reports.cpp와 queued_handoff.hpp를 복사하고, 같은 폴더에 다음 CMakeLists.txt를 만듭니다. queued_handoff.hpp는 반드시 reports.cpp 옆에 있어야 합니다.
cmake_minimum_required(VERSION 3.20)project(pdcomm_qt_reports LANGUAGES CXX)set(CMAKE_CXX_STANDARD 17)set(CMAKE_CXX_STANDARD_REQUIRED ON)set(PDCOMM_SDK "" CACHE PATH "Folder that contains include/ and lib/ of the PDComm SDK")find_package(Qt6 REQUIRED COMPONENTS Core)find_library(PDCOMM_FFI NAMES pdcomm_ffi pdcomm_ffi.dll PATHS "${PDCOMM_SDK}/lib" NO_DEFAULT_PATH REQUIRED)add_executable(pdcomm_qt_reports reports.cpp)target_include_directories(pdcomm_qt_reports PRIVATE "${PDCOMM_SDK}/include")target_link_libraries(pdcomm_qt_reports PRIVATE Qt6::Core "${PDCOMM_FFI}")set_target_properties(pdcomm_qt_reports PROPERTIES BUILD_RPATH "${PDCOMM_SDK}/lib")빌드하고 실행합니다
섹션 제목: “빌드하고 실행합니다”cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DPDCOMM_SDK="$PDCOMM_SDK"cmake --build build./build/pdcomm_qt_reports equipment passive 127.0.0.1:0Qt를 찾지 못하면 -DCMAKE_PREFIX_PATH=<Qt 설치 경로>를 추가합니다. Windows에서는 sdk\lib\pdcomm_ffi.dll.lib에 링크하고 실행 파일 옆에 pdcomm_ffi.dll을 둡니다.
장비가 LISTEN 127.0.0.1:<port>를 출력하면 다른 터미널에서 호스트를 실행합니다.
./build/pdcomm_qt_reports host active 127.0.0.1:54543양쪽 모두 다음 줄을 출력하고 0으로 끝납니다.
PASS QT REPORTSQt 장비와 .NET 호스트는 같은 시나리오를 쓰므로 서로 짝지어 실행할 수도 있습니다. 인수 없이 실행하면 사용법이 나옵니다.
usage: pdcomm_qt_reports ROLE MODE ADDRESS [--cancel]전체 코드: qt/reports.cpp
#include "pdcomm_gem.hpp"#include "queued_handoff.hpp"
#include <QCoreApplication>#include <QThread>#include <QTimer>
#include <atomic>#include <chrono>#include <cstdint>#include <cstdio>#include <cstring>#include <memory>#include <stdexcept>#include <string>#include <thread>#include <utility>#include <vector>
namespace {
PdcommSecsLimits itemLimits() { return pdcomm::secsLimits(4096, 4096, 128, 8);}
PdcommTcpOptions tcpOptions(uint32_t mode) { PdcommTcpOptions value = {}; value.struct_size = sizeof(value); value.version = PDCOMM_TCP_OPTIONS_VERSION; value.mode = mode; value.session_id = 7; value.initial_system_bytes = 1000; value.t3_ms = 2000; value.t5_ms = 50; value.t6_ms = 2000; value.t7_ms = 2000; value.t8_ms = 1000; value.connect_timeout_ms = 20; value.shutdown_timeout_ms = 200; value.write_timeout_ms = 2000; value.max_frame_bytes = 4110; value.max_events = 96; value.max_queued_bytes = 32768; value.max_write_frames = 8; value.max_write_bytes = 16384; value.max_io_chunk = 3; value.max_application_events = 96; value.max_application_bytes = 32768; value.poll_budget = 16; value.item_limits = itemLimits(); return value;}
pdcomm::SecsItem f4(float value) { uint32_t bits = 0; std::memcpy(&bits, &value, sizeof(bits)); const std::vector<uint8_t> bytes = { static_cast<uint8_t>(bits >> 24), static_cast<uint8_t>(bits >> 16), static_cast<uint8_t>(bits >> 8), static_cast<uint8_t>(bits)}; return pdcomm::SecsItem::primitive(PDCOMM_SECS_F4, bytes, itemLimits());}
pdcomm::SecsItem ascii(const std::string &value) { return pdcomm::SecsItem::primitive( PDCOMM_SECS_ASCII, reinterpret_cast<const uint8_t *>(value.data()), value.size(), itemLimits());}
bool asciiEquals(const pdcomm::SecsItem &item, const std::string &value) { return item.info().format == PDCOMM_SECS_ASCII && item.payload() == std::vector<uint8_t>(value.begin(), value.end());}
bool sameToken(const PdcommTcpToken &left, const PdcommTcpToken &right) { return left.endpoint_id == right.endpoint_id && left.generation == right.generation && left.system_bytes == right.system_bytes;}
void require(bool condition, const char *message) { if (!condition) throw std::runtime_error(message);}
void stopAndDrain(pdcomm::GemEndpoint &endpoint) { if (!endpoint.tcp().state().closed) endpoint.stop(); const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2); while (true) { if (!endpoint.tcp().state().closed) endpoint.poll(); for (auto event = endpoint.nextEvent(); event; event = endpoint.nextEvent()) { (void)endpoint.handle(event); } if (endpoint.tcp().state().closed || std::chrono::steady_clock::now() >= deadline) break; std::this_thread::sleep_for(std::chrono::milliseconds(1)); } require(endpoint.tcp().state().closed, "GEM shutdown timed out");}
PdcommTcpToken sendStep(pdcomm::GemHost &host, size_t step) { switch (step) { case 0: return host.defineReports(1, {{10, {2, 1, 2}}, {20, {1}}}); case 1: return host.linkReports(2, {{100, {10, 20}}, {200, {20}}}); case 2: return host.enableEvents(true); case 3: return host.enableEvents(false, {100}); case 4: return host.defineReports(3, {{20, {}}}); case 5: return host.enableEvents(true, {100}); case 6: return host.defineReports(4, {}); case 7: return host.enableEvents(true, {200}); default: return host.linkReports(5, {{200, {10}}}); }}
struct UiPayload { bool cancelled = false; bool host = false; std::vector<std::shared_ptr<pdcomm::GemResult>> events; std::vector<std::shared_ptr<pdcomm::SecsItem>> retainedValues;};
struct SharedState { std::atomic<bool> active{false}; std::atomic<bool> succeeded{false}; std::atomic<int> uiDeliveries{0}; std::atomic<int> activeHeartbeats{0}; std::string failure;};
class ReportsWorker final : public QThread {public: ReportsWorker(QCoreApplication *application, std::string role, std::string mode, std::string address, std::shared_ptr<SharedState> shared) : application_(application), role_(std::move(role)), mode_(std::move(mode)), address_(std::move(address)), shared_(std::move(shared)) {}
protected: void run() override { shared_->active.store(true); try { require(QThread::currentThread() != application_->thread(), "GEM endpoint ran on the UI thread"); auto payload = role_ == "host" ? runHost() : runEquipment(); postCompletion(std::move(payload)); } catch (const pdcomm::Error &error) { shared_->failure = "PDComm status " + std::to_string(error.status()); } catch (const std::exception &error) { shared_->failure = error.what(); } shared_->active.store(false); }
private: void postListen(const std::string &address) { auto *application = application_; const auto shared = shared_; require(pdcomm_qt_example::invokeQueued( application, [application, shared, address] { if (QThread::currentThread() != application->thread()) return; shared->uiDeliveries.fetch_add(1); std::fprintf(stdout, "LISTEN %s\n", address.c_str()); std::fflush(stdout); }), "failed to queue listen notification"); }
pdcomm::TcpEndpoint makeEndpoint() { const bool activeMode = mode_ == "active"; pdcomm::TcpEndpoint endpoint( address_, tcpOptions(activeMode ? PDCOMM_TCP_ACTIVE : PDCOMM_TCP_PASSIVE)); if (!activeMode) postListen(endpoint.localAddress()); return endpoint; }
uint8_t requestUiDecision( const std::shared_ptr<pdcomm::GemResult> &result, size_t index) { const auto decision = std::make_shared<std::atomic<int>>(-1); auto *application = application_; const auto shared = shared_; require(pdcomm_qt_example::invokeQueued( application, [application, shared, result, decision, index] { try { require(QThread::currentThread() == application->thread(), "event inspection missed the UI thread"); const auto info = result->info(); const auto event = result->eventInfo(); require(info.outcome == pdcomm::GemOutcome::EventReceived && !info.replySent && !event.acknowledged, "event result state mismatch"); require(event.dataId == (index == 0 ? 41U : 42U) && event.eventId == 100 && event.reportCount == (index == 0 ? 2U : 1U), "event identity mismatch"); const auto report = result->eventReport(0); require(report.id == 10 && report.valueCount == 3, "event report shape mismatch"); require(asciiEquals(result->value(0, 0), "LOT-001") && asciiEquals(result->value(0, 2), "LOT-001"), "event lot snapshot mismatch"); const auto temperature = result->value(0, 1); require(temperature.info().format == PDCOMM_SECS_F4 && temperature.payload() == f4(index == 0 ? 21.5F : 32.0F).payload(), "event temperature snapshot mismatch"); if (index == 0) { const auto second = result->eventReport(1); require(second.id == 20 && second.valueCount == 1 && result->value(1, 0).payload() == f4(21.5F).payload(), "second report snapshot mismatch"); } shared->uiDeliveries.fetch_add(1); decision->store(index == 0 ? 0 : 7, std::memory_order_release); } catch (...) { decision->store(-2, std::memory_order_release); } }), "failed to queue event inspection");
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2); int value = -1; while ((value = decision->load(std::memory_order_acquire)) == -1 && std::chrono::steady_clock::now() < deadline && !isInterruptionRequested()) { std::this_thread::sleep_for(std::chrono::milliseconds(1)); } require(value >= 0, "queued event decision failed"); return static_cast<uint8_t>(value); }
UiPayload runHost() { UiPayload payload; payload.host = true; { auto endpoint = makeEndpoint(); pdcomm::GemHost host( std::move(endpoint), pdcomm::gemHostOptions(itemLimits(), 64)); host.start(); bool beginSent = false; bool sentFirst = false; bool waitingForEvent = false; size_t step = 0; PdcommTcpToken requested[9] = {}; // Step 7 enables CEID 200 after S2F33 removed every report; the CEID // exists, so ERACK is 0. Step 8 links an RPTID that no longer exists. const uint8_t expectedAck[] = {0, 0, 0, 0, 0, 0, 0, 0, 5}; const pdcomm::GemRequest expectedRequest[] = { pdcomm::GemRequest::DefineReports, pdcomm::GemRequest::LinkReports, pdcomm::GemRequest::EnableEvents, pdcomm::GemRequest::EnableEvents, pdcomm::GemRequest::DefineReports, pdcomm::GemRequest::EnableEvents, pdcomm::GemRequest::DefineReports, pdcomm::GemRequest::EnableEvents, pdcomm::GemRequest::LinkReports}; const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(15); while (step < 9 && std::chrono::steady_clock::now() < deadline && !isInterruptionRequested()) { host.poll(); if (!beginSent && host.tcp().state().state == PDCOMM_TCP_SELECTED) { host.beginCommunication(); beginSent = true; } std::shared_ptr<pdcomm::GemResult> pendingEvent; for (auto event = host.nextEvent(); event; event = host.nextEvent()) { auto result = host.handle(event); const auto info = result.info(); if (info.outcome == pdcomm::GemOutcome::CommunicationEstablished) { if (!sentFirst) { requested[0] = sendStep(host, 0); sentFirst = true; } } else if (info.outcome == pdcomm::GemOutcome::EventReceived) { require(waitingForEvent && payload.events.size() < 2, "unexpected event report"); pendingEvent = std::make_shared<pdcomm::GemResult>( std::move(result)); } else if (info.kind == pdcomm::GemResultKind::Response) { require(step < 9 && info.request == expectedRequest[step] && sameToken(info.token, requested[step]) && info.acknowledgementPresent && info.acknowledgement == expectedAck[step], "configuration response mismatch"); ++step; if (step == 3 || step == 6) { waitingForEvent = true; } else if (step < 9) { requested[step] = sendStep(host, step); } } } if (pendingEvent) { const size_t index = payload.events.size(); const uint8_t ack = requestUiDecision(pendingEvent, index); payload.retainedValues.push_back( std::make_shared<pdcomm::SecsItem>( pendingEvent->value(0, 1))); host.acknowledgeEvent(*pendingEvent, ack); payload.events.push_back(std::move(pendingEvent)); waitingForEvent = false; requested[step] = sendStep(host, step); } std::this_thread::sleep_for(std::chrono::milliseconds(1)); } if (isInterruptionRequested()) { payload.cancelled = true; } else { require(step == 9 && payload.events.size() == 2, "host report exchange timed out"); } stopAndDrain(host); } return payload; }
UiPayload runEquipment() { UiPayload payload; { auto endpoint = makeEndpoint(); pdcomm::GemEquipment equipment( std::move(endpoint), itemLimits(), pdcomm::GemControl::HostOffline, pdcomm::GemOnlineMode::Remote, "PDComm", "0.1.0", 8, 1024, 4096); equipment.configureReports( pdcomm::gemReportOptions(4, 4, 8, 4, 16, 16), {100, 200}); equipment.registerStatus(1, "Temperature", "C", f4(21.5F)); equipment.registerStatus(2, "Lot", "", ascii("LOT-001")); equipment.start(); size_t primaries = 0; size_t eventAcks = 0; PdcommTcpToken published[2] = {}; bool peerStopped = false; // Step 7 enables CEID 200 after S2F33 removed every report; the CEID // exists, so ERACK is 0. Step 8 links an RPTID that no longer exists. const uint8_t expectedAck[] = {0, 0, 0, 0, 0, 0, 0, 0, 5}; const pdcomm::GemOutcome expectedPrimaryOutcome[] = { pdcomm::GemOutcome::DefineReportsAck, pdcomm::GemOutcome::LinkReportsAck, pdcomm::GemOutcome::EnableEventsAck, pdcomm::GemOutcome::EnableEventsAck, pdcomm::GemOutcome::DefineReportsAck, pdcomm::GemOutcome::EnableEventsAck, pdcomm::GemOutcome::DefineReportsAck, pdcomm::GemOutcome::EnableEventsAck, pdcomm::GemOutcome::LinkReportsAck}; const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(15); while (std::chrono::steady_clock::now() < deadline && !isInterruptionRequested()) { equipment.poll(); for (auto event = equipment.nextEvent(); event; event = equipment.nextEvent()) { auto result = equipment.handle(event); const auto info = result.info(); if (info.kind == pdcomm::GemResultKind::Primary && (info.outcome == pdcomm::GemOutcome::DefineReportsAck || info.outcome == pdcomm::GemOutcome::LinkReportsAck || info.outcome == pdcomm::GemOutcome::EnableEventsAck)) { require(primaries < 9 && info.outcome == expectedPrimaryOutcome[primaries] && info.acknowledgementPresent && info.acknowledgement == expectedAck[primaries], "equipment configuration mismatch"); if (primaries == 2) { published[0] = equipment.publishEvent(41, 100); equipment.updateStatus(1, f4(32.0F)); } else if (primaries == 5) { published[1] = equipment.publishEvent(42, 100); } ++primaries; } else if (info.kind == pdcomm::GemResultKind::Response && info.outcome == pdcomm::GemOutcome::EventAck) { require(eventAcks < 2 && info.request == pdcomm::GemRequest::EventReport && info.acknowledgementPresent && info.acknowledgement == (eventAcks == 0 ? 0 : 7) && sameToken(info.token, published[eventAcks]), "equipment event ACK mismatch"); ++eventAcks; } } if (primaries == 9 && eventAcks == 2 && equipment.tcp().state().state != PDCOMM_TCP_SELECTED) { peerStopped = true; break; } std::this_thread::sleep_for(std::chrono::milliseconds(1)); } if (isInterruptionRequested()) { payload.cancelled = true; } else { require(primaries == 9 && eventAcks == 2 && peerStopped, "equipment report exchange timed out"); } stopAndDrain(equipment); } return payload; }
void postCompletion(UiPayload payload) { const auto completion = std::make_shared<std::atomic<int>>(0); auto *application = application_; const auto shared = shared_; require(pdcomm_qt_example::invokeQueued( application, [application, shared, payload, completion] { try { require(QThread::currentThread() == application->thread(), "completion missed the UI thread"); if (!payload.cancelled && payload.host) { require(payload.events.size() == 2 && payload.retainedValues.size() == 2, "retained host results are missing"); require(payload.events[0]->eventInfo().acknowledged && payload.events[1]->eventInfo().acknowledged, "retained result ACK state mismatch"); require(payload.retainedValues[0]->payload() == f4(21.5F).payload() && payload.retainedValues[1]->payload() == f4(32.0F).payload(), "retained event values changed"); } shared->uiDeliveries.fetch_add(1); completion->store(1); } catch (...) { completion->store(-1); } }), "failed to queue completion"); const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2); while (completion->load() == 0 && std::chrono::steady_clock::now() < deadline) { std::this_thread::sleep_for(std::chrono::milliseconds(1)); } require(completion->load() == 1, "queued completion validation failed"); require(shared_->activeHeartbeats.load() > 0, "UI heartbeat did not run while worker was active"); shared_->succeeded.store(true); }
QCoreApplication *application_; std::string role_; std::string mode_; std::string address_; std::shared_ptr<SharedState> shared_;};
} // namespace
int main(int argc, char **argv) { QCoreApplication application(argc, argv); if (argc < 4 || argc > 5 || (std::string(argv[1]) != "host" && std::string(argv[1]) != "equipment") || (std::string(argv[2]) != "active" && std::string(argv[2]) != "passive") || (argc == 5 && std::string(argv[4]) != "--cancel")) { std::fprintf(stderr, "usage: pdcomm_qt_reports ROLE MODE ADDRESS [--cancel]\n"); return 2; }
const bool cancel = argc == 5; const auto shared = std::make_shared<SharedState>(); ReportsWorker worker(&application, argv[1], argv[2], argv[3], shared); QTimer heartbeat; heartbeat.setInterval(1); QObject::connect(&heartbeat, &QTimer::timeout, &application, [shared, &application] { if (QThread::currentThread() == application.thread() && shared->active.load()) { shared->activeHeartbeats.fetch_add(1); } }); heartbeat.start();
QTimer cancelTimer; cancelTimer.setSingleShot(true); QObject::connect(&cancelTimer, &QTimer::timeout, &application, [&worker] { worker.requestInterruption(); }); QTimer watchdog; watchdog.setSingleShot(true); QObject::connect(&watchdog, &QTimer::timeout, &application, [&worker] { worker.requestInterruption(); }); QObject::connect(&worker, &QThread::started, &application, [&] { watchdog.start(18000); if (cancel) cancelTimer.start(20); }); QObject::connect(&worker, &QThread::finished, &application, [&] { worker.wait(); QTimer::singleShot(0, &application, [&] { if (!shared->succeeded.load() || shared->uiDeliveries.load() == 0 || shared->activeHeartbeats.load() == 0) { std::fprintf(stderr, "FAIL QT REPORTS: %s\n", shared->failure.empty() ? "UI/worker validation failed" : shared->failure.c_str()); application.exit(1); return; } std::fprintf(stdout, "PASS QT REPORTS\n"); std::fflush(stdout); application.quit(); }); });
worker.start(); const int result = application.exec(); worker.requestInterruption(); worker.wait(); return result;}시뮬레이터로 확인합니다
섹션 제목: “시뮬레이터로 확인합니다”GUI 시뮬레이터에서도 같은 흐름을 손으로 따라갈 수 있습니다.
- 장비 역할 창에서 Reports & Events 작업공간의 Catalog 탭을 엽니다. Load example을 누르고 Use on next start로 예약한 뒤 세션을 시작합니다. 예제 카탈로그는 RPTID 1(VID 1, 2), RPTID 2(VID 3), CEID 10(RPTID 1, 2 연결, 켜짐)과 VID 바인딩(VID 1·2는 SVID 1001·1002, VID 3은 DVID 2001)입니다. 시작하면 Actual session catalog에 실제 구성이 나타납니다.
- 호스트 역할 창에서 통신을 수립합니다. 예제 카탈로그의 CEID 10은 이미 켜져 있으므로 원격 설정 없이 바로 보고를 받을 수 있습니다. 호스트가 직접 보고서를 만들어 보려면 Remote configuration 탭의 Define reports, Link reports, Apply event state를 씁니다.
- 장비 창의 Traffic 탭에서 DATAID와 CEID(예: 10)를 넣고 Emit을 누릅니다. 호스트 창의 Traffic 탭에서 받은 이벤트를 고르고 ACKC6 값을 정한 뒤 Acknowledge를 누릅니다.




자세한 화면 설명은 시뮬레이터 GUI 가이드를 참고합니다.
자주 만나는 오류
섹션 제목: “자주 만나는 오류”| 증상 | 원인 | 해결 |
|---|---|---|
요청이 PdcommException(InvalidState)으로 실패합니다. |
통신 수립(S1F13) 전에 요청했습니다. |
CommunicationEstablished를 받은 뒤 보냅니다. |
S2F38 ERACK 1 |
장비에 없는 CEID를 지정했습니다. | 장비가 제공하는 CEID인지 확인합니다. 보고서 연결 여부와는 관계없습니다. |
S2F36 LRACK 5 |
연결하려는 RPTID가 없습니다. | S2F33으로 보고서를 먼저 정의합니다. |
장비가 S6F11을 보내지 않습니다. |
이벤트가 꺼져 있습니다. 장비 쪽 PublishEvent도 이때 실패합니다. |
S2F37로 켰는지, S2F38 응답이 0이었는지 확인합니다. |
S6F11에 보고서가 하나도 없습니다. |
켜진 이벤트에 연결된 보고서가 없습니다. | S2F35로 보고서를 연결하고 S2F36 응답이 0인지 확인합니다. |
장비가 S6F12를 받지 못하고 T3 뒤에 실패합니다. |
호스트가 AcknowledgeEventAsync를 호출하지 않았습니다. |
보고서를 처리한 뒤 반드시 응답합니다. |
| 보고서 값의 순서가 예상과 다릅니다. | S6F11 값에는 VID가 없습니다. |
호스트가 정의한 VID 순서대로 해석합니다. |
- 예제는 끝나면 스스로 종료합니다. 중간에 멈췄다면 각 터미널에서 Ctrl+C로 끝냅니다.
- 빌드 결과물은
$PDCOMM_ROOT/build/managed와 Qt 예제 폴더의build에 있습니다. 필요 없으면 지웁니다.