데이터 수집
GEM 장비(equipment)는 값을 변수(variable)로 공개하고, 호스트(host)는 그 값을 읽거나 보고서로 받습니다. 이 튜토리얼은 데이터 수집에 쓰는 PDComm 기능을 네 개의 .NET 예제로 실행합니다.
| 절 | 예제 | 메시지 |
|---|---|---|
| 데이터 변수와 보고서 바인딩 | PDComm.GemDataDemo |
S2F33, S2F35, S2F37, S6F11, S1F3 |
| 장비 상수 | PDComm.GemConstantsDemo |
S2F13, S2F15, S2F29 |
| 변수·이벤트 디렉터리 | PDComm.GemDirectoriesDemo |
S1F21, S1F23 |
| 보고서 조회 | PDComm.GemQueriesDemo |
S6F15, S6F19, S6F21 |
상태 변수(SVID)를 S1F3/S1F11로 읽는 가장 짧은 예는 첫 통신에서 다룹니다.
- 상태 변수, 데이터 변수(DVID), 장비 상수(ECID)의 차이와 PDComm에서 각각을 등록하는 방법을 이해합니다.
- 보고서의 VID를 실제 값의 출처에 연결하는 바인딩(binding)을 사용합니다.
- 장비 상수 조회·변경, 디렉터리 조회, 보고서 조회를 실행하고 응답 코드를 읽습니다.
준비물
섹션 제목: “준비물”- 환경 준비를 마친 SDK와 셸입니다. 이 페이지는
PDCOMM_ROOT,PDCOMM_SDK,PDCOMM_NATIVE,PDCOMM_EXAMPLES(Windows는$Root,$Sdk,$Native,$Examples)를 씁니다. - .NET 10 SDK입니다.
- 보고서와 이벤트의
S2F33/S2F35/S2F37흐름을 알고 있으면 2절과 5절을 읽기 쉽습니다.
1. 예제 네 개를 빌드합니다
섹션 제목: “1. 예제 네 개를 빌드합니다”foreach ($demo in 'PDComm.GemDataDemo', 'PDComm.GemConstantsDemo', 'PDComm.GemDirectoriesDemo', 'PDComm.GemQueriesDemo') { dotnet build "$Examples\dotnet\$demo\$demo.csproj" -c Release "-p:PDCommSdk=$Sdk" --artifacts-path "$Root\build\managed"}for demo in PDComm.GemDataDemo PDComm.GemConstantsDemo PDComm.GemDirectoriesDemo PDComm.GemQueriesDemo; do dotnet build "$PDCOMM_EXAMPLES/dotnet/$demo/$demo.csproj" -c Release "-p:PDCommSdk=$PDCOMM_SDK" --artifacts-path "$PDCOMM_ROOT/build/managed"donefor demo in PDComm.GemDataDemo PDComm.GemConstantsDemo PDComm.GemDirectoriesDemo PDComm.GemQueriesDemo; do dotnet build "$PDCOMM_EXAMPLES/dotnet/$demo/$demo.csproj" -c Release "-p:PDCommSdk=$PDCOMM_SDK" --artifacts-path "$PDCOMM_ROOT/build/managed"done각 예제마다 Build succeeded.가 나오고, 결과물은 build/managed/bin/<예제 이름>/release/<예제 이름>.dll에 생깁니다.
모든 예제는 같은 방법으로 실행합니다. 첫 번째 터미널에서 장비를 127.0.0.1:0으로 시작하고, 출력된 LISTEN 주소를 두 번째 터미널의 호스트에 넘깁니다. 아래 명령의 127.0.0.1:54459는 실행할 때마다 바뀌는 예시 포트입니다.
2. 데이터 변수와 보고서 바인딩
섹션 제목: “2. 데이터 변수와 보고서 바인딩”- 상태 변수(status variable, SV) 는 장비가 항상 알고 있는 현재 값입니다. 호스트는
S1F3으로 언제든 읽습니다. - 데이터 변수(data variable, DV) 는 특정 이벤트가 일어날 때만 의미가 있는 값입니다. 예를 들어 방금 끝난 웨이퍼의 측정값입니다. GEM에서는 이벤트 보고서로만 받습니다.
- 보고서 바인딩은 호스트가
S2F33에 쓰는 VID를 실제 출처(상태 변수, 데이터 변수, 장비 상수)에 연결합니다. 바인딩을 설정하지 않으면 PDComm은 VID를 같은 번호의 상태 변수로 해석합니다.
예제의 구성
섹션 제목: “예제의 구성”Fixture.StartEquipment는 GemDataConfiguration로 데이터 변수 다섯 개와 바인딩 열두 개를 설치합니다.
| 외부 VID | 출처(GemDataSourceKind) |
내용 |
|---|---|---|
| 101, 102 | Status 1, 2 |
Temperature(F4 21.5), Lot(ASCII LOT-001) |
| 201–204 | Data 1–4 |
Count(U4, 캐시 10), Sample(ASCII, 캐시 없음), Tree(리스트), Bytes(바이너리) |
| 211 | Data 1 |
201과 같은 Count를 가리키는 별칭 |
| 301–305 | Constant |
장비 상수 CycleLimit, Label, Gain, Counter, Empty |
호스트는 RPTID 10 = VID [102, 201, 301, 202, 201, 203, 204, 101], RPTID 20 = VID [211, 302, 303, 304, 305]을 정의하고 CEID 100에 연결한 뒤 켭니다. 그러면 장비가 DATAID 700–705로 S6F11 여섯 개를 보냅니다.
| DATAID | 장비 API | Count(VID 201·211) |
Sample(VID 202) |
호스트 ACKC6 |
|---|---|---|---|---|
| 700 | PublishEventWithDataAsync |
샘플 20 | 샘플 SHOT-A |
0 |
| 701 | PublishEventAsync |
캐시 30 | 캐시 CACHE-B |
7 |
| 702 | PublishEventWithDataAsync |
캐시 40 | 샘플 SHOT-C |
0 |
| 703 | PublishEventWithDataAsync |
캐시 50 | 샘플 ""(빈 문자열) |
63 |
| 704 | PublishEventAsync |
캐시 50 | 캐시 CACHE-E |
0 |
| 705 | PublishEventWithDataAsync |
샘플 60 | 샘플 LAST |
0 |
마지막으로 호스트는 S1F3으로 SVID 1을 읽어, 첫 보고 직후 장비가 바꾼 값 32.0을 확인합니다.
실행합니다
섹션 제목: “실행합니다”# Terminal 1dotnet "$Root\build\managed\bin\PDComm.GemDataDemo\release\PDComm.GemDataDemo.dll" $Native equipment passive 127.0.0.1:0# Terminal 2dotnet "$Root\build\managed\bin\PDComm.GemDataDemo\release\PDComm.GemDataDemo.dll" $Native host active 127.0.0.1:54459# Terminal 1dotnet "$PDCOMM_ROOT/build/managed/bin/PDComm.GemDataDemo/release/PDComm.GemDataDemo.dll" "$PDCOMM_NATIVE" equipment passive 127.0.0.1:0# Terminal 2dotnet "$PDCOMM_ROOT/build/managed/bin/PDComm.GemDataDemo/release/PDComm.GemDataDemo.dll" "$PDCOMM_NATIVE" host active 127.0.0.1:54459# Terminal 1dotnet "$PDCOMM_ROOT/build/managed/bin/PDComm.GemDataDemo/release/PDComm.GemDataDemo.dll" "$PDCOMM_NATIVE" equipment passive 127.0.0.1:0# Terminal 2dotnet "$PDCOMM_ROOT/build/managed/bin/PDComm.GemDataDemo/release/PDComm.GemDataDemo.dll" "$PDCOMM_NATIVE" host active 127.0.0.1:54459장비는 LISTEN 127.0.0.1:<port>를 출력한 뒤 기다리고, 호스트가 끝나면 두 프로그램이 각각 다음 줄을 출력하고 종료 코드 0으로 끝납니다.
PASS host activePASS equipment passive코드에서 볼 곳
섹션 제목: “코드에서 볼 곳”PublishEventWithDataAsync는GemDataSample(내부 DV ID와 값)를 받습니다. 값을 정하는 순서는 샘플, 캐시, 실패입니다. 샘플은 이 보고서에만 쓰이고 캐시를 바꾸지 않습니다.- 보고서에 쓰이지 않는 샘플도 개수, 형식, 크기를 모두 검사합니다. 예제의 DV 5
Unused샘플이 그 경우입니다. - 캐시가 없는(지워진) 데이터 변수를 샘플 없이 보고하면 보고 전체가 실패합니다(
InvalidState). 캐시 없음은 빈 값과 다릅니다. - 샘플 목록이 비어 있으면
PublishEventWithDataAsync는PublishEventAsync와 같습니다. 이때는 데이터 변수 구성(GemDataConfiguration)이 없어도 됩니다. 샘플을 하나라도 주려면 데이터 변수 구성이 있어야 하며, 없으면InvalidState로 실패합니다. - 보고할 이벤트는 켜져 있어야 합니다. 켜진 이벤트에 연결된 보고서가 없으면 장비는 보고서 목록이 빈
S6F11을 보냅니다. UpdateDataVariableAsync와ClearDataVariableAsync가 캐시를 바꿉니다. SDK는 값을 스스로 수집하지 않습니다. 장치에서 값을 읽어 넣는 일은 애플리케이션이 합니다.
전체 코드: PDComm.GemDataDemo/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.GemDataDemo <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;using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(25));await using GemWorker worker = role == "host" ? await GemHostWorker.StartAsync(args[3], Options.Transport(mode), Options.Host, Options.Worker, deadline.Token) : await Fixture.StartEquipment(args[3], mode, deadline.Token);if (mode == HsmsMode.Passive){ Console.WriteLine($"LISTEN {worker.LocalAddress ?? throw new InvalidOperationException("Missing passive address.")}"); Console.Out.Flush();}await using IAsyncEnumerator<GemEvent> events = worker.ReadEventsAsync(deadline.Token).GetAsyncEnumerator();if (worker is GemHostWorker host) await Fixture.RunHost(host, events, deadline.Token);else await Fixture.RunEquipment((GemEquipmentWorker)worker, events, deadline.Token);while (await events.MoveNextAsync()) events.Current.Dispose();await worker.Completion.WaitAsync(deadline.Token);Console.WriteLine($"PASS {role} {args[2]}");return 0;
internal static class Fixture{ private const int EventCount = 6;
internal static async Task<GemEquipmentWorker> StartEquipment( string address, HsmsMode mode, CancellationToken token) { var owned = new List<SecsItem>(); try { SecsItem Own(SecsItem item) { owned.Add(item); return item; } var status = new GemStatusVariable[] { new(1, "Temperature", "C", Own(SecsItem.FromF4([21.5f], Options.Limits))), new(2, "Lot", "", Own(SecsItem.FromAscii("LOT-001", Options.Limits))), }; var constants = new GemConstantDefinition[8]; for (int index = 0; index < constants.Length; index++) { SecsItem id = Own(ConstantId(index)); SecsItem current = Own(ConstantValue(index)); SecsItem? defaultValue = index < 4 || index == 5 ? Own(ConstantMetadata(index, GemConstantField.Default)) : null; SecsItem? minimum = index is 0 or 2 or 5 or 6 ? Own(ConstantMetadata(index, GemConstantField.Minimum)) : null; SecsItem? maximum = index is 0 or 2 or 5 or 6 ? Own(ConstantMetadata(index, GemConstantField.Maximum)) : null; constants[index] = new GemConstantDefinition(id, ConstantNames[index], index is 0 or 3 ? "count" : "", current, defaultValue, minimum, maximum, index is 0 or 2 or 3 ? 1u : index == 5 ? 2u : 0u, index == 1 ? 16u : index is 4 or 6 ? 4u : index == 7 ? 8u : index == 5 ? 2u : 1u); } var dataDefinitions = new GemDataDefinition[5]; for (int index = 0; index < dataDefinitions.Length; index++) { SecsItem? cached = InitialData(index); if (cached is not null) Own(cached); dataDefinitions[index] = new GemDataDefinition((uint)index + 1, DataNames[index], index == 0 ? "count" : "", DataFormats[index], index == 0 ? 1u : 0u, index == 0 ? 1u : index == 2 ? 4u : 32u, cached); } var bindings = new GemReportBinding[12]; uint[] vids = [101, 102, 201, 202, 203, 204, 301, 302, 303, 304, 305, 211]; int[] constantIndices = [0, 1, 2, 3, 7]; for (int index = 0; index < bindings.Length; index++) { if (index < 2) bindings[index] = new GemReportBinding(vids[index], GemDataSourceKind.Status, (uint)index + 1, null); else if (index < 6 || index == 11) bindings[index] = new GemReportBinding(vids[index], GemDataSourceKind.Data, index == 11 ? 1u : (uint)index - 1, null); else bindings[index] = new GemReportBinding(vids[index], GemDataSourceKind.Constant, 0, Own(ConstantId(constantIndices[index - 6]))); } var data = new GemDataConfiguration(Options.Data, dataDefinitions, Options.Bindings, bindings); return await GemEquipmentWorker.StartAsync(address, Options.Transport(mode), Options.Equipment, Options.Worker, status, new GemReportConfiguration(Options.Reports, new uint[] { 100 }), null, null, null, data, new GemConstantConfiguration(Options.Constants, constants), token); } finally { foreach (SecsItem item in owned) item.Dispose(); } }
internal static async Task RunHost(GemHostWorker host, IAsyncEnumerator<GemEvent> events, CancellationToken token) { await WaitSelected(host, token); HsmsToken pending = await host.BeginCommunicationAsync(token); int step = -1; int received = 0; while (step < 3) { if (!await events.MoveNextAsync()) throw new InvalidOperationException("Host event stream ended early."); GemEvent current = events.Current; bool disposed = false; try { GemResult? result = current.Result; if (result is null) continue; GemResultInfo info = result.GetInfo(); if (info.Kind == GemResultKind.Response && info.Token == pending) { if (step < 0) { if (info.Outcome != GemOutcome.CommunicationEstablished) throw new InvalidOperationException("Communication establishment failed."); step = 0; pending = await SendHostStep(host, step, token); } else if (step < 3) { CheckHostResponse(result, step); if (step < 2) pending = await SendHostStep(host, ++step, token); } } else if (info.Outcome == GemOutcome.EventReceived) { CheckEvent(result, received); byte acknowledgement = Acknowledgement(received); Task response = host.AcknowledgeEventAsync(result, acknowledgement, token); if (received == 0) { SecsItem retained = result.GetEventValue(0, 0); current.Dispose(); disposed = true; using (retained) using (SecsItem expected = Expected(0, 0, 0)) Equal(retained, expected); } await response; received++; if (received == EventCount) { step = 3; pending = await SendHostStep(host, step, token); } } } finally { if (!disposed) current.Dispose(); } }
while (true) { if (!await events.MoveNextAsync()) throw new InvalidOperationException("Host status response was not received."); using GemEvent current = events.Current; GemResult? result = current.Result; if (result is null || result.GetInfo().Token != pending) continue; CheckHostResponse(result, 3); break; } if (received != EventCount) throw new InvalidOperationException("Event count mismatch."); await host.StopAsync(token); }
internal static async Task RunEquipment(GemEquipmentWorker equipment, IAsyncEnumerator<GemEvent> events, CancellationToken token) { int configured = 0; int published = 0; int acknowledged = 0; HsmsToken publication = default; while (acknowledged < EventCount) { if (!await events.MoveNextAsync()) throw new InvalidOperationException("Equipment event stream ended early."); using GemEvent current = events.Current; GemResult? result = current.Result; if (result is null) continue; GemResultInfo info = result.GetInfo(); if (info.Kind == GemResultKind.Primary && info.Outcome is >= GemOutcome.DefineReportsAck and <= GemOutcome.EnableEventsAck) { configured++; if (info.Acknowledgement != 0) throw new InvalidOperationException("Host configuration was rejected."); if (info.Outcome == GemOutcome.EnableEventsAck) publication = await Publish(equipment, published++, token); } else if (info.Kind == GemResultKind.Response && info.Outcome == GemOutcome.EventAck) { if (info.Token != publication || info.Acknowledgement != Acknowledgement(acknowledged)) throw new InvalidOperationException("Event acknowledgement mismatch."); acknowledged++; if (published < EventCount) publication = await Publish(equipment, published++, token); } } if (configured != 3 || published != EventCount) throw new InvalidOperationException("Equipment scenario count mismatch."); while ((await equipment.GetTransportStateAsync(token)).State == HsmsConnectionState.Selected) await Task.Delay(1, token); await equipment.StopAsync(token); }
private static async Task<HsmsToken> SendHostStep( GemHostWorker host, int step, CancellationToken token) => step switch { 0 => await host.DefineReportsAsync(1, new GemReportSpec[] { new(10, new uint[] { 102, 201, 301, 202, 201, 203, 204, 101 }), new(20, new uint[] { 211, 302, 303, 304, 305 }), }, token), 1 => await host.LinkReportsAsync(2, new GemLinkSpec[] { new(100, new uint[] { 10, 20 }) }, token), 2 => await host.EnableEventsAsync(true, new uint[] { 100 }, token), 3 => await host.RequestStatusValuesAsync(new uint[] { 1 }, token), _ => throw new ArgumentOutOfRangeException(nameof(step)), };
private static void CheckHostResponse(GemResult result, int step) { GemResultInfo info = result.GetInfo(); GemRequestKind[] kinds = [GemRequestKind.DefineReports, GemRequestKind.LinkReports, GemRequestKind.EnableEvents, GemRequestKind.StatusValues]; GemOutcome[] outcomes = [GemOutcome.DefineReportsAck, GemOutcome.LinkReportsAck, GemOutcome.EnableEventsAck, GemOutcome.ValuesReceived]; if (info.Kind != GemResultKind.Response || info.RequestKind != kinds[step] || info.Outcome != outcomes[step]) throw new InvalidOperationException("Host response mismatch."); if (step < 3) { if (info.Acknowledgement != 0) throw new InvalidOperationException("Configuration acknowledgement mismatch."); } else { using GemStatusValue value = result.GetStatusValue(0); using SecsItem expected = SecsItem.FromF4([32f], Options.Limits); Equal(value.Value, expected); } }
private static async Task<HsmsToken> Publish( GemEquipmentWorker equipment, int ordinal, CancellationToken token) { var owned = new List<SecsItem>(); try { var samples = new List<GemDataSample>(); void Add(uint id, SecsItem value) { owned.Add(value); samples.Add(new GemDataSample(id, value)); } if (ordinal is 0 or 5) Add(1, SecsItem.FromU4([ordinal == 0 ? 20u : 60u], Options.Limits)); if (ordinal is 0 or 2 or 3 or 5) Add(2, SecsItem.FromAscii(ordinal == 0 ? "SHOT-A" : ordinal == 2 ? "SHOT-C" : ordinal == 3 ? "" : "LAST", Options.Limits)); if (ordinal is 2 or 5) Add(3, ordinal == 2 ? DataTree(true) : List()); if (ordinal is 3 or 5) Add(4, SecsItem.FromBinary(ordinal == 3 ? [9, 8] : [], Options.Limits)); if (ordinal is 0 or 5) Add(5, SecsItem.FromAscii("UNUSED-SAMPLE", Options.Limits)); HsmsToken result = ordinal is 1 or 4 ? await equipment.PublishEventAsync((uint)(700 + ordinal), 100, token) : await equipment.PublishEventWithDataAsync((uint)(700 + ordinal), 100, samples.ToArray(), token); await AfterPublish(equipment, ordinal, token); return result; } finally { foreach (SecsItem item in owned) item.Dispose(); } }
private static async Task AfterPublish( GemEquipmentWorker equipment, int ordinal, CancellationToken token) { async Task Update(uint id, SecsItem value) { using (value) await equipment.UpdateDataVariableAsync(id, value, token); } switch (ordinal) { case 0: await Update(1, SecsItem.FromU4([30], Options.Limits)); await Update(2, SecsItem.FromAscii("CACHE-B", Options.Limits)); using (SecsItem temperature = SecsItem.FromF4([32f], Options.Limits)) await equipment.UpdateStatusAsync(1, temperature, token); break; case 1: await equipment.ClearDataVariableAsync(1, token); await equipment.ClearDataVariableAsync(2, token); await Update(1, SecsItem.FromU4([40], Options.Limits)); break; case 2: await Update(1, SecsItem.FromU4([50], Options.Limits)); await Update(2, SecsItem.FromAscii("CACHE-D", Options.Limits)); break; case 3: await Update(2, SecsItem.FromAscii("CACHE-E", Options.Limits)); break; case 4: await equipment.ClearDataVariableAsync(2, token); break; case 5: for (uint id = 1; id <= 4; id++) await equipment.ClearDataVariableAsync(id, token); break; } }
private static void CheckEvent(GemResult result, int ordinal) { GemEventInfo info = result.GetEventInfo(); if (info.DataId != (uint)(700 + ordinal) || info.EventId != 100 || info.ReportCount != 2) throw new InvalidOperationException("Event metadata mismatch."); for (nuint reportIndex = 0; reportIndex < 2; reportIndex++) { GemEventReport report = result.GetEventReport(reportIndex); nuint expectedCount = reportIndex == 0 ? 8u : 5u; if (report.ReportId != (reportIndex == 0 ? 10u : 20u) || report.ValueCount != expectedCount) throw new InvalidOperationException("Event report mismatch."); for (nuint valueIndex = 0; valueIndex < expectedCount; valueIndex++) { using SecsItem actual = result.GetEventValue(reportIndex, valueIndex); using SecsItem expected = Expected(ordinal, (int)reportIndex, (int)valueIndex); Equal(actual, expected); } } }
private static SecsItem Expected(int ordinal, int report, int value) { if (report == 1 && value != 0) return ConstantValue(new[] { 1, 2, 3, 7 }[value - 1]); if (report == 1 || value is 1 or 4) return SecsItem.FromU4([new uint[] { 20, 30, 40, 50, 50, 60 }[ordinal]], Options.Limits); return value switch { 0 => SecsItem.FromAscii("LOT-001", Options.Limits), 2 => ConstantValue(0), 3 => SecsItem.FromAscii(new[] { "SHOT-A", "CACHE-B", "SHOT-C", "", "CACHE-E", "LAST" }[ordinal], Options.Limits), 5 => ordinal == 5 ? List() : DataTree(ordinal == 2), 6 => SecsItem.FromBinary(ordinal == 3 ? [9, 8] : [], Options.Limits), 7 => SecsItem.FromF4([ordinal == 0 ? 21.5f : 32f], Options.Limits), _ => throw new ArgumentOutOfRangeException(nameof(value)), }; }
private static byte Acknowledgement(int ordinal) => new byte[] { 0, 7, 0, 63, 0, 0 }[ordinal];
private static async Task WaitSelected(GemWorker worker, CancellationToken token) { while ((await worker.GetTransportStateAsync(token)).State != HsmsConnectionState.Selected) await Task.Delay(1, token); }
private static readonly string[] ConstantNames = ["CycleLimit", "Label", "Gain", "Counter", "Tree", "Flags", "Bytes", "Empty"]; private static readonly string[] DataNames = ["Count", "Sample", "Tree", "Bytes", "Unused"]; private static readonly SecsFormat[] DataFormats = [SecsFormat.U4, SecsFormat.Ascii, SecsFormat.List, SecsFormat.Binary, SecsFormat.Ascii];
private static SecsItem? InitialData(int index) => index switch { 0 => SecsItem.FromU4([10], Options.Limits), 1 => null, 2 => DataTree(false), 3 => SecsItem.FromBinary([], Options.Limits), 4 => SecsItem.FromAscii("UNUSED", Options.Limits), _ => throw new ArgumentOutOfRangeException(nameof(index)), };
private static SecsItem DataTree(bool sampled) => List( SecsItem.FromU4([sampled ? 8u : 7u], Options.Limits), SecsItem.FromAscii(sampled ? "SAMPLED" : "NESTED", Options.Limits));
private static SecsItem ConstantId(int index) => index switch { 0 => SecsItem.FromU1([1], Options.Limits), 1 => SecsItem.FromAscii("LABEL", Options.Limits), 2 => SecsItem.FromI8([-1], Options.Limits), 3 => SecsItem.FromU8([ulong.MaxValue], Options.Limits), 4 => SecsItem.FromAscii("TREE", Options.Limits), 5 => SecsItem.FromAscii("FLAGS", Options.Limits), 6 => SecsItem.FromAscii("BYTES", Options.Limits), 7 => SecsItem.FromAscii("", Options.Limits), _ => throw new ArgumentOutOfRangeException(nameof(index)), };
private static SecsItem ConstantValue(int index) => index switch { 0 => SecsItem.FromU4([10], Options.Limits), 1 => SecsItem.FromAscii("IDLE", Options.Limits), 2 => SecsItem.FromF8([1.5], Options.Limits), 3 => SecsItem.FromU8([ulong.MaxValue], Options.Limits), 4 => List(SecsItem.FromU4([7], Options.Limits), SecsItem.FromAscii("OK", Options.Limits)), 5 => SecsItem.FromBoolean([false, true], Options.Limits), 6 => SecsItem.FromBinary([1, 2], Options.Limits), 7 => SecsItem.FromAscii("", Options.Limits), _ => List(), };
private static SecsItem ConstantMetadata(int index, GemConstantField field) { if (field == GemConstantField.Default) return ConstantValue(index); if (index == 0) return SecsItem.FromU4([field == GemConstantField.Minimum ? 1u : 100u], Options.Limits); if (index == 2) return SecsItem.FromF8([field == GemConstantField.Minimum ? -5d : 5d], Options.Limits); if (index == 5) return SecsItem.FromBoolean([field != GemConstantField.Minimum], Options.Limits); if (index == 6) return SecsItem.FromBinary([field == GemConstantField.Minimum ? (byte)0 : (byte)255], Options.Limits); return SecsItem.FromAscii("", Options.Limits); }
private static SecsItem List(params SecsItem[] children) { try { return SecsItem.CreateList(children, Options.Limits); } finally { foreach (SecsItem child in children) child.Dispose(); } }
private static void Equal(SecsItem actual, SecsItem expected) { if (!actual.Encode().AsSpan().SequenceEqual(expected.Encode())) throw new InvalidOperationException("SECS-II value mismatch."); }}
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 GemReportOptions Reports = new() { MaxEvents = 4, MaxReports = 4, MaxVariablesPerReport = 8, MaxReportsPerEvent = 4, MaxTotalVariables = 16, MaxTotalLinks = 16, }; internal static readonly GemConstantOptions Constants = new() { MaxConstants = 8, MaxMetadataBytes = 1024, MaxValueBytes = 4096, MaxQueryIds = 32, MaxChangesPerRequest = 16, MaxIdentifierBytes = 32, ItemLimits = Limits, }; internal static readonly GemDataOptions Data = new() { MaxVariables = 8, MaxMetadataBytes = 512, MaxValueBytes = 4096, MaxSamplesPerEvent = 5, MaxSampleBytes = 4096, ItemLimits = Limits, }; internal static readonly GemBindingOptions Bindings = new() { MaxBindings = 16, MaxIdentifierBytes = 32, MaxTotalIdentifierBytes = 64, }; internal static readonly HsmsWorkerOptions Worker = new() { CommandCapacity = 64, EventCapacity = 64, MaxCommandBytes = 131072, MaxEventBytes = 131072, PollIntervalMilliseconds = 1, ShutdownTimeoutMilliseconds = 2000, }; 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, };}C API에서는 pdcomm_gem_equipment_configure_data_variables, pdcomm_gem_equipment_configure_report_bindings, pdcomm_gem_equipment_publish_event_with_data, pdcomm_gem_equipment_update_data_variable, pdcomm_gem_equipment_clear_data_variable을 씁니다.
3. 장비 상수
섹션 제목: “3. 장비 상수”장비 상수(equipment constant, EC)는 호스트가 바꿀 수 있는 장비 설정값입니다. 호스트는 S2F29로 이름·범위·기본값·단위 같은 메타데이터를, S2F13으로 현재 값을 읽고, S2F15로 값을 바꿉니다. 장비는 S2F16의 EAC로 결과를 알립니다.
| EAC | 의미 |
|---|---|
| 0 | 수락하고 적용했습니다. |
| 1 | 없는 ECID가 하나 이상 있습니다. |
| 2 | 지금은 처리할 수 없습니다(busy). |
| 3 | 범위를 벗어난 값이 하나 이상 있습니다. |
예제의 구성
섹션 제목: “예제의 구성”StartEquipment는 GemConstantConfiguration에 GemConstantDefinition 여덟 개를 담습니다. ECID는 정수 항목(U1(1), I8(-1), U8)이나 ASCII 항목("LABEL", "TREE", 빈 문자열)일 수 있습니다. 예를 들어 CycleLimit(ECID U1(1))은 값 U4 10, 최솟값 1, 최댓값 100, 단위 count입니다. Label(ECID "LABEL")은 ASCII이고 최대 16자입니다.
호스트는 24단계를 보냅니다. 주요 단계는 다음과 같습니다.
S2F29와S2F13에 빈 목록을 보내 여덟 개 전부를 받습니다.S2F14에는 ECID가 실리지 않으므로, 모든 값을 요청한 결과의 항목에는 ECID가 비어 있습니다(null). ID를 지정해 요청하면 SDK가 요청한 ID를 짝지어 줍니다.- 없는 ECID를 섞어 조회하면, 그 자리에는 메타데이터 필드가 빈 항목, 값은 길이 0인 리스트가 옵니다. 순서와 중복은 요청 그대로 유지됩니다.
S2F15로CycleLimit=20,Label=RUN을 바꾸면 EAC 0으로 적용됩니다.- 없는 ECID(EAC 1), 최댓값을 넘는 101, 다른 형식(F8)으로 보낸 값, NaN, 최대 길이를 넘는 문자열(모두 EAC 3)은 거부됩니다.
- 검사를 통과한 요청에 장비 애플리케이션이
CannotPerform을 고르면 EAC 2가 됩니다.
실행합니다
섹션 제목: “실행합니다”# Terminal 1dotnet "$Root\build\managed\bin\PDComm.GemConstantsDemo\release\PDComm.GemConstantsDemo.dll" $Native equipment passive 127.0.0.1:0# Terminal 2dotnet "$Root\build\managed\bin\PDComm.GemConstantsDemo\release\PDComm.GemConstantsDemo.dll" $Native host active 127.0.0.1:54463# Terminal 1dotnet "$PDCOMM_ROOT/build/managed/bin/PDComm.GemConstantsDemo/release/PDComm.GemConstantsDemo.dll" "$PDCOMM_NATIVE" equipment passive 127.0.0.1:0# Terminal 2dotnet "$PDCOMM_ROOT/build/managed/bin/PDComm.GemConstantsDemo/release/PDComm.GemConstantsDemo.dll" "$PDCOMM_NATIVE" host active 127.0.0.1:54463# Terminal 1dotnet "$PDCOMM_ROOT/build/managed/bin/PDComm.GemConstantsDemo/release/PDComm.GemConstantsDemo.dll" "$PDCOMM_NATIVE" equipment passive 127.0.0.1:0# Terminal 2dotnet "$PDCOMM_ROOT/build/managed/bin/PDComm.GemConstantsDemo/release/PDComm.GemConstantsDemo.dll" "$PDCOMM_NATIVE" host active 127.0.0.1:54463결과는 PASS host active와 PASS equipment passive이고, 두 프로그램 모두 종료 코드 0으로 끝납니다.
코드에서 볼 곳
섹션 제목: “코드에서 볼 곳”- 호스트:
RequestConstantMetadataAsync(S2F29),RequestConstantValuesAsync(S2F13),ChangeConstantsAsync(S2F15). - 장비:
S2F15는Outcome이ConstantChangesReceived인 결과로 도착하고 자동 응답하지 않습니다. 애플리케이션이RespondConstantsAsync에GemConstantDecision(Apply,CannotPerform,OutOfRange)을 넘겨 응답합니다. - SDK는 응답하기 전에 ECID 존재, 형식, 범위, 개수를 먼저 검사합니다. 검사에서 걸리면 애플리케이션이
Apply를 골라도 EAC 1이나 3을 보내고 값을 바꾸지 않습니다. 결과의Applied로 실제 적용 여부를 확인합니다.
전체 코드: PDComm.GemConstantsDemo/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.GemConstantsDemo <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;using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(25));await using GemWorker worker = role == "host" ? await GemHostWorker.StartAsync(args[3], Options.Transport(mode), Options.Host, Options.Worker, deadline.Token) : await StartEquipment(args[3], mode, deadline.Token);if (mode == HsmsMode.Passive) { Console.WriteLine($"LISTEN {worker.LocalAddress ?? throw new InvalidOperationException("Missing passive address.")}"); Console.Out.Flush(); }await using IAsyncEnumerator<GemEvent> events = worker.ReadEventsAsync(deadline.Token).GetAsyncEnumerator();if (worker is GemHostWorker host) await RunHost(host, events, deadline.Token); else await RunEquipment((GemEquipmentWorker)worker, events, deadline.Token);while (await events.MoveNextAsync()) events.Current.Dispose();await worker.Completion.WaitAsync(deadline.Token);Console.WriteLine($"PASS {role} {args[2]}");return 0;
static async Task<GemEquipmentWorker> StartEquipment(string address, HsmsMode mode, CancellationToken token){ var owned = new List<SecsItem>(); try { SecsItem Item(int index, int phase = 0) { SecsItem item = Value(index, phase); owned.Add(item); return item; } var definitions = new GemConstantDefinition[8]; for (int i = 0; i < 8; i++) { SecsItem id = Id(i); owned.Add(id); SecsItem current = Item(i); SecsItem? def = i < 4 || i == 5 ? Metadata(i, GemConstantField.Default) : null; if (def is not null) owned.Add(def); SecsItem? min = i is 0 or 2 or 5 or 6 ? Metadata(i, GemConstantField.Minimum) : null; if (min is not null) owned.Add(min); SecsItem? max = i is 0 or 2 or 5 or 6 ? Metadata(i, GemConstantField.Maximum) : null; if (max is not null) owned.Add(max); definitions[i] = new(id, Names()[i], i is 0 or 3 ? "count" : "", current, def, min, max, i is 0 or 2 or 3 ? 1u : i == 5 ? 2u : 0u, i == 1 ? 16u : i is 4 or 6 ? 4u : i == 7 ? 8u : i == 5 ? 2u : 1u); } return await GemEquipmentWorker.StartAsync(address, Options.Transport(mode), Options.Equipment, Options.Worker, null, null, null, null, null, new GemConstantConfiguration(Options.Constants, definitions), token); } finally { foreach (SecsItem item in owned) item.Dispose(); }}
static async Task RunHost(GemHostWorker host, IAsyncEnumerator<GemEvent> events, CancellationToken token){ bool began = false, established = false; int next = 0, completed = 0; HsmsToken pending = default; while (completed < 24) { if (!began && (await host.GetTransportStateAsync(token)).State == HsmsConnectionState.Selected) { pending = await host.BeginCommunicationAsync(token); began = true; } if (!await events.MoveNextAsync()) throw new InvalidOperationException("Host event stream ended early."); using GemEvent current = events.Current; GemResult? result = current.Result; if (result is null) continue; GemResultInfo info = result.GetInfo(); if (info.Kind != GemResultKind.Response) continue; if (info.Token != pending) throw new InvalidOperationException("Token mismatch."); if (!established) { if (info.Outcome != GemOutcome.CommunicationEstablished) throw new InvalidOperationException("Communication failed."); established = true; pending = await Send(host, next++, token); continue; } Check(result, completed++); if (next < 24) pending = await Send(host, next++, token); } await host.StopAsync(token);}
static async Task<HsmsToken> Send(GemHostWorker host, int step, CancellationToken token){ if (IsQuery(step)) { int[] indices = Query(step); var ids = new SecsItem[indices.Length]; try { for (int i = 0; i < ids.Length; i++) ids[i] = step == 3 && i == 3 ? SecsItem.FromI8([1], Options.Limits) : Id(indices[i]); return Kind(step) == GemRequestKind.ConstantMetadata ? await host.RequestConstantMetadataAsync(ids, token) : await host.RequestConstantValuesAsync(ids, token); } finally { foreach (SecsItem item in ids) item?.Dispose(); } } if (step == 11) return await host.ChangeConstantsAsync(Array.Empty<GemConstantChange>(), token); (int id, SecsItem value)[] changes = Changes(step); var items = new List<SecsItem>(); try { var request = new GemConstantChange[changes.Length]; for (int i = 0; i < changes.Length; i++) { SecsItem id = step == 7 && i == 1 ? SecsItem.FromI8([1], Options.Limits) : Id(changes[i].id); items.Add(id); items.Add(changes[i].value); request[i] = new(id, changes[i].value); } return await host.ChangeConstantsAsync(request, token); } finally { foreach (SecsItem item in items) item.Dispose(); }}
static async Task RunEquipment(GemEquipmentWorker equipment, IAsyncEnumerator<GemEvent> events, CancellationToken token){ int primaries = 0, changes = 0, applied = 0; while (primaries < 25 || changes < 16) { if (!await events.MoveNextAsync()) throw new InvalidOperationException("Equipment event stream ended early."); GemEvent current = events.Current; bool disposed = false; try { GemResult? result = current.Result; if (result is null) continue; GemResultInfo info = result.GetInfo(); if (info.Kind == GemResultKind.Primary) primaries++; if (info.Outcome != GemOutcome.ConstantChangesReceived) continue; int step = ChangeSteps()[changes++]; GemConstantChangeInfo detail = result.GetConstantChangeInfo(); if (!detail.IsRequest || detail.Responded || detail.ChangeCount != (nuint)(step == 11 ? 0 : step is 4 or 6 or 7 or 17 ? 2 : 1)) throw new InvalidOperationException("Receipt metadata mismatch."); for (nuint i = 0; i < detail.ChangeCount; i++) using (GemConstantChangeEntry entry = result.GetConstantChange(i)) { if (entry.Id is null || entry.Value is null) throw new InvalidOperationException("Receipt fields missing."); } GemConstantDecision decision = (GemConstantDecision)Decision(step); Task<GemConstantReply> response = equipment.RespondConstantsAsync(result, decision, token); current.Dispose(); disposed = true; GemConstantReply reply = await response; if (reply.Acknowledgement != Ack(step) || reply.Applied != (step is 4 or 16 or 17 or 21)) throw new InvalidOperationException("Receipt response mismatch."); if (reply.Applied) applied++; } finally { if (!disposed) current.Dispose(); } } if (primaries != 25 || applied != 4) throw new InvalidOperationException("Equipment counts mismatch."); while ((await equipment.GetTransportStateAsync(token)).State == HsmsConnectionState.Selected) await Task.Delay(1, token); await equipment.StopAsync(token);}
static void Check(GemResult result, int step){ GemResultInfo info = result.GetInfo(); if (!IsQuery(step)) { if (info.Outcome != GemOutcome.ConstantChangeAck || info.Acknowledgement != Ack(step)) throw new InvalidOperationException("Change ACK mismatch."); if (result.GetConstantChangeInfo().IsRequest) throw new InvalidOperationException("Change result direction mismatch."); return; } int[] selected = Query(step); int count = selected.Length == 0 ? 8 : selected.Length; int phase = step <= 3 ? 0 : step == 5 ? 1 : step == 23 ? 3 : 2; if ((int)info.EntryCount != count) throw new InvalidOperationException("Query count mismatch."); for (nuint i = 0; i < (nuint)count; i++) { int index = selected.Length == 0 ? (int)i : selected[i]; if (Kind(step) == GemRequestKind.ConstantMetadata) { for (GemConstantField field = GemConstantField.Id; field <= GemConstantField.Units; field++) { using SecsItem? actual = result.GetConstantMetadata(i, field); using SecsItem expected = Metadata(index, field); Equal(actual, expected); } } else { using GemConstantValue actual = result.GetConstantValue(i); using SecsItem expected = index >= 8 ? List() : Value(index, phase); Equal(actual.Value, expected); if (selected.Length == 0) { if (actual.Id is not null) throw new InvalidOperationException("All-values ID must be null."); } else { using SecsItem expectedId = step == 3 && i == 3 ? SecsItem.FromI8([1], Options.Limits) : Id(index); Equal(actual.Id, expectedId); } } }}
static bool IsQuery(int step) => step <= 3 || step is 5 or 18 or 22 or 23;static GemRequestKind Kind(int step) => step is 0 or 2 or 22 ? GemRequestKind.ConstantMetadata : IsQuery(step) ? GemRequestKind.ConstantValues : GemRequestKind.ChangeConstants;static int[] Query(int step) => step is 0 or 1 or 23 ? [] : step == 2 ? [1, 8, 0, 2, 3, 7, 1] : step == 3 ? [4, 8, 0, 0] : step == 5 ? [0, 1] : step == 18 ? [4, 2, 3] : [4, 1, 0];static int[] ChangeSteps() => [4, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 19, 20, 21];static byte Ack(int step) => step is 6 or 19 ? (byte)1 : step is 7 or 8 or 9 or 10 or 14 or 20 ? (byte)3 : step == 12 ? (byte)2 : step == 13 ? (byte)4 : step == 15 ? (byte)63 : (byte)0;static byte Decision(int step) => step == 12 ? (byte)2 : step == 13 ? (byte)4 : step == 14 ? (byte)3 : step == 15 ? (byte)63 : (byte)0;static (int, SecsItem)[] Changes(int step) => step switch { 4 => [(0, Value(0, 1)), (1, Value(1, 1))], 6 => [(0, SecsItem.FromU4([101], Options.Limits)), (8, SecsItem.FromAscii("UNKNOWN", Options.Limits))], 7 => [(0, SecsItem.FromU4([30], Options.Limits)), (0, SecsItem.FromU4([40], Options.Limits))], 8 => [(0, SecsItem.FromU4([101], Options.Limits))], 9 => [(0, SecsItem.FromF8([20], Options.Limits))], 10 => [(2, SecsItem.FromF8([double.NaN], Options.Limits))], 12 => [(1, SecsItem.FromAscii("BUSY", Options.Limits))], 13 => [(1, SecsItem.FromAscii("SPEC", Options.Limits))], 14 => [(1, SecsItem.FromAscii("REJECT", Options.Limits))], 15 => [(1, SecsItem.FromAscii("MAX", Options.Limits))], 16 => [(4, Value(4, 2))], 17 => [(2, Value(2, 2)), (3, Value(3, 2))], 19 => [(9, SecsItem.FromAscii("x", Options.Limits))], 20 => [(1, SecsItem.FromAscii("01234567890123456", Options.Limits))], 21 => [(7, Value(7, 3))], _ => throw new InvalidOperationException("Invalid change step.") };static string[] Names() => ["CycleLimit", "Label", "Gain", "Counter", "Tree", "Flags", "Bytes", "Empty"];static SecsItem Id(int index) => index switch { 0 => SecsItem.FromU1([1], Options.Limits), 1 => SecsItem.FromAscii("LABEL", Options.Limits), 2 => SecsItem.FromI8([-1], Options.Limits), 3 => SecsItem.FromU8([ulong.MaxValue], Options.Limits), 4 => SecsItem.FromAscii("TREE", Options.Limits), 5 => SecsItem.FromAscii("FLAGS", Options.Limits), 6 => SecsItem.FromAscii("BYTES", Options.Limits), 7 => SecsItem.FromAscii("", Options.Limits), 8 => SecsItem.FromAscii("UNKNOWN", Options.Limits), 9 => SecsItem.FromAscii("label", Options.Limits), _ => throw new ArgumentOutOfRangeException(nameof(index)) };static SecsItem Value(int index, int phase = 0) => index switch { 0 => SecsItem.FromU4([phase == 0 ? 10u : 20u], Options.Limits), 1 => SecsItem.FromAscii(phase == 0 ? "IDLE" : "RUN", Options.Limits), 2 => SecsItem.FromF8([phase < 2 ? 1.5 : 2.5], Options.Limits), 3 => SecsItem.FromU8([phase < 2 ? ulong.MaxValue : ulong.MaxValue - 1], Options.Limits), 4 => Tree(phase), 5 => SecsItem.FromBoolean([false, true], Options.Limits), 6 => SecsItem.FromBinary([1, 2], Options.Limits), 7 => SecsItem.FromAscii(phase < 3 ? "" : "EMPTY", Options.Limits), _ => throw new ArgumentOutOfRangeException(nameof(index)) };static SecsItem Tree(int phase) => phase < 2 ? List(SecsItem.FromU4([7], Options.Limits), SecsItem.FromAscii("OK", Options.Limits)) : List(SecsItem.FromBoolean([true], Options.Limits), SecsItem.FromBinary([1, 2], Options.Limits), List(SecsItem.FromAscii("NESTED", Options.Limits)));static SecsItem Metadata(int index, GemConstantField field) { if (field == GemConstantField.Id) return index == 0 ? SecsItem.FromU8([1], Options.Limits) : Id(index); if (index >= 8) return SecsItem.FromAscii("", Options.Limits); if (field == GemConstantField.Name) return SecsItem.FromAscii(Names()[index], Options.Limits); if (field == GemConstantField.Units) return SecsItem.FromAscii(index is 0 or 3 ? "count" : "", Options.Limits); if (field == GemConstantField.Default) return index < 4 || index == 5 ? Value(index) : SecsItem.FromAscii("", Options.Limits); if (index == 0) return SecsItem.FromU4([field == GemConstantField.Minimum ? 1u : 100u], Options.Limits); if (index == 2) return SecsItem.FromF8([field == GemConstantField.Minimum ? -5d : 5d], Options.Limits); if (index == 5) return SecsItem.FromBoolean([field != GemConstantField.Minimum], Options.Limits); if (index == 6) return SecsItem.FromBinary([field == GemConstantField.Minimum ? (byte)0 : (byte)255], Options.Limits); return SecsItem.FromAscii("", Options.Limits); }static SecsItem List(params SecsItem[] children) { try { return SecsItem.CreateList(children, Options.Limits); } finally { foreach (SecsItem item in children) item.Dispose(); } }static void Equal(SecsItem? actual, SecsItem expected) { if (actual is null || !actual.Encode().AsSpan().SequenceEqual(expected.Encode())) throw new InvalidOperationException("Fixture item mismatch."); }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 GemConstantOptions Constants = new() { MaxConstants = 8, MaxMetadataBytes = 1024, MaxValueBytes = 4096, MaxQueryIds = 32, MaxChangesPerRequest = 16, MaxIdentifierBytes = 32, ItemLimits = Limits }; internal static readonly HsmsWorkerOptions Worker = new() { CommandCapacity = 64, EventCapacity = 64, MaxCommandBytes = 131072, MaxEventBytes = 131072, PollIntervalMilliseconds = 1, ShutdownTimeoutMilliseconds = 2000 }; 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 }; }C API에서는 pdcomm_gem_host_constant_request, pdcomm_gem_host_change_constants, pdcomm_gem_equipment_respond_constants을 씁니다.
4. 변수와 이벤트 디렉터리
섹션 제목: “4. 변수와 이벤트 디렉터리”디렉터리(directory)는 값이 아니라 이름표입니다.
S1F21/S1F22(데이터 변수 이름 목록)는 VID마다 이름과 단위를 돌려줍니다.S1F23/S1F24(수집 이벤트 이름 목록)는 CEID마다 이름과 그 이벤트에 쓸 수 있는 VID 목록을 돌려줍니다.
예제의 구성
섹션 제목: “예제의 구성”장비는 데이터 변수 네 개(Count, Sample, Tree, Bytes)와 바인딩(VID 201–204, 별칭 211), 그리고 정적 이벤트 디렉터리 하나를 설치합니다. 이벤트 디렉터리는 GemEventDirectoryDefinition(CEID 100, 이름 DataSnapshot, VID [201, 202, 211])로 정합니다.
| 호스트 요청 | 장비 응답 |
|---|---|
S1F21 빈 목록 |
VID 201, 202, 203, 204, 211과 각각의 이름·단위 |
S1F21 [211, 999, 101, 201, 301, 202, 211] |
같은 순서의 7개 항목. 데이터 변수에 묶이지 않은 999, 101, 301은 이름과 단위가 빈 문자열입니다. |
S1F23 빈 목록 |
CEID 100 DataSnapshot, VID [201, 202, 211] |
S1F23 [999, 100, 100] |
같은 순서의 3개 항목. 999는 이름과 VID 목록이 비어 있습니다. |
장비는 첫 S1F21에 응답한 뒤 Count의 캐시를 지웁니다. 디렉터리는 캐시와 무관하므로 다음 조회 결과는 그대로입니다.
실행합니다
섹션 제목: “실행합니다”# Terminal 1dotnet "$Root\build\managed\bin\PDComm.GemDirectoriesDemo\release\PDComm.GemDirectoriesDemo.dll" $Native equipment passive 127.0.0.1:0# Terminal 2dotnet "$Root\build\managed\bin\PDComm.GemDirectoriesDemo\release\PDComm.GemDirectoriesDemo.dll" $Native host active 127.0.0.1:54468# Terminal 1dotnet "$PDCOMM_ROOT/build/managed/bin/PDComm.GemDirectoriesDemo/release/PDComm.GemDirectoriesDemo.dll" "$PDCOMM_NATIVE" equipment passive 127.0.0.1:0# Terminal 2dotnet "$PDCOMM_ROOT/build/managed/bin/PDComm.GemDirectoriesDemo/release/PDComm.GemDirectoriesDemo.dll" "$PDCOMM_NATIVE" host active 127.0.0.1:54468# Terminal 1dotnet "$PDCOMM_ROOT/build/managed/bin/PDComm.GemDirectoriesDemo/release/PDComm.GemDirectoriesDemo.dll" "$PDCOMM_NATIVE" equipment passive 127.0.0.1:0# Terminal 2dotnet "$PDCOMM_ROOT/build/managed/bin/PDComm.GemDirectoriesDemo/release/PDComm.GemDirectoriesDemo.dll" "$PDCOMM_NATIVE" host active 127.0.0.1:54468결과는 PASS host active와 PASS equipment passive입니다.
호스트는 RequestDirectoryAsync에 GemDirectoryKind(Data 또는 Event)를 넘깁니다. 장비 쪽은 자동으로 응답하므로 따로 처리할 코드가 없습니다. 이벤트 디렉터리는 GemDirectoryConfiguration로 시작 전에 설치합니다.
전체 코드: PDComm.GemDirectoriesDemo/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.GemDirectoriesDemo <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;using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(25));await using GemWorker worker = role == "host" ? await GemHostWorker.StartAsync(args[3], Setup.Transport(mode), Setup.Host, Setup.Worker, cancellation.Token) : await Setup.StartEquipment(args[3], mode, cancellation.Token);if (mode == HsmsMode.Passive){ Console.WriteLine($"LISTEN {worker.LocalAddress ?? throw new InvalidOperationException("Missing passive address.")}"); Console.Out.Flush();}await using IAsyncEnumerator<GemEvent> events = worker.ReadEventsAsync(cancellation.Token).GetAsyncEnumerator();if (worker is GemHostWorker host) await RunHost(host, events, cancellation.Token);else await RunEquipment((GemEquipmentWorker)worker, events, cancellation.Token);while (await events.MoveNextAsync()) events.Current.Dispose();await worker.Completion.WaitAsync(cancellation.Token);Console.WriteLine($"PASS {role} {args[2]}");return 0;
static async Task RunHost(GemHostWorker host, IAsyncEnumerator<GemEvent> events, CancellationToken token){ await WaitSelected(host, token); HsmsToken pending = await host.BeginCommunicationAsync(token); int step = -1; while (step < 10) { if (!await events.MoveNextAsync()) throw new InvalidOperationException("Host event stream ended early."); using GemEvent current = events.Current; GemResult? result = current.Result; if (result is null) continue; GemResultInfo info = result.GetInfo(); if (info.Kind != GemResultKind.Response || info.Token != pending) continue; if (step < 0) { if (info.Outcome != GemOutcome.CommunicationEstablished) throw new InvalidOperationException("Communication establishment failed."); step = 0; pending = await Send(host, step, token); continue; } Verify(result, step); if (++step < 10) pending = await Send(host, step, token); } await host.StopAsync(token);}
static async Task RunEquipment(GemEquipmentWorker equipment, IAsyncEnumerator<GemEvent> events, CancellationToken token){ bool cleared = false; await WaitSelected(equipment, token); while (await events.MoveNextAsync()) { using GemEvent current = events.Current; GemResult? result = current.Result; if (result is not null && result.GetInfo().Outcome == GemOutcome.DataDirectoryReplied && !cleared) { await equipment.ClearDataVariableAsync(1, token); cleared = true; } if (result is not null && result.GetInfo().Outcome == GemOutcome.StatusReplied) { await equipment.StopAsync(token); break; } } if (!cleared) throw new InvalidOperationException("The data directory reply was not observed.");}
static async Task<HsmsToken> Send(GemHostWorker host, int step, CancellationToken token) => step switch{ 0 or 7 => await host.RequestDirectoryAsync(GemDirectoryKind.Data, Array.Empty<uint>(), token), 1 => await host.RequestDirectoryAsync(GemDirectoryKind.Data, new uint[] { 211, 999, 101, 201, 301, 202, 211 }, token), 2 or 8 => await host.RequestDirectoryAsync(GemDirectoryKind.Event, Array.Empty<uint>(), token), 3 => await host.RequestDirectoryAsync(GemDirectoryKind.Event, new uint[] { 999, 100, 100 }, token), 4 => await host.DefineReportsAsync(1, new GemReportSpec[] { new(10, new uint[] { 201 }) }, token), 5 => await host.LinkReportsAsync(2, new GemLinkSpec[] { new(100, new uint[] { 10 }) }, token), 6 => await host.EnableEventsAsync(true, new uint[] { 100 }, token), 9 => await host.RequestStatusValuesAsync(new uint[] { 1 }, token), _ => throw new ArgumentOutOfRangeException(nameof(step)),};
static void Verify(GemResult result, int step){ GemResultInfo info = result.GetInfo(); if (step is >= 4 and <= 6) { GemRequestKind[] kinds = [GemRequestKind.DefineReports, GemRequestKind.LinkReports, GemRequestKind.EnableEvents]; GemOutcome[] outcomes = [GemOutcome.DefineReportsAck, GemOutcome.LinkReportsAck, GemOutcome.EnableEventsAck]; int index = step - 4; if (info.Kind != GemResultKind.Response || info.RequestKind != kinds[index] || info.Outcome != outcomes[index] || info.Acknowledgement != 0 || info.ReplySent) throw new InvalidOperationException("Unexpected configuration response."); return; } if (step == 9) { if (info.Kind != GemResultKind.Response || info.RequestKind != GemRequestKind.StatusValues || info.Outcome != GemOutcome.ValuesReceived || info.EntryCount != 1 || info.ReplySent) throw new InvalidOperationException("Unexpected status response metadata."); using GemStatusValue value = result.GetStatusValue(0); if (value.Id != 1 || value.Value is null || value.Value.GetF4()[0] != 21.5f) throw new InvalidOperationException("Unexpected status response."); return; } bool data = step is 0 or 1 or 7; uint[] expected = data ? step == 1 ? [211, 999, 101, 201, 301, 202, 211] : [201, 202, 203, 204, 211] : step == 3 ? [999, 100, 100] : [100]; if (info.Outcome != (data ? GemOutcome.DataDirectoryReceived : GemOutcome.EventDirectoryReceived) || info.EntryCount != (nuint)expected.Length) throw new InvalidOperationException("Unexpected directory response."); for (nuint index = 0; index < (nuint)expected.Length; index++) { GemDirectoryEntryInfo entry = result.GetDirectoryAt(index); if (entry.Id != expected[(int)index]) throw new InvalidOperationException("Directory order changed."); using SecsItem name = result.GetDirectory(index, GemDirectoryField.Name); if (data) { using SecsItem units = result.GetDirectory(index, GemDirectoryField.Units); string expectedName = entry.Id is 201 or 211 ? "Count" : entry.Id == 202 ? "Sample" : entry.Id == 203 ? "Tree" : entry.Id == 204 ? "Bytes" : ""; string expectedUnits = entry.Id is 201 or 211 ? "count" : ""; if (name.GetAscii() != expectedName || units.GetAscii() != expectedUnits) throw new InvalidOperationException("Data metadata mismatch."); } else { using SecsItem ids = result.GetDirectory(index, GemDirectoryField.VariableIds); uint[] expectedIds = entry.Id == 100 ? [201, 202, 211] : []; if (name.GetAscii() != (entry.Id == 100 ? "DataSnapshot" : "") || !ids.GetU4().SequenceEqual(expectedIds)) throw new InvalidOperationException("Event metadata mismatch."); } }}
static async Task WaitSelected(GemWorker worker, CancellationToken token){ while ((await worker.GetTransportStateAsync(token)).State != HsmsConnectionState.Selected) await Task.Delay(1, token);}
internal static class Setup{ 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 GemDataOptions Data = new() { MaxVariables = 8, MaxMetadataBytes = 512, MaxValueBytes = 4096, MaxSamplesPerEvent = 5, MaxSampleBytes = 4096, ItemLimits = Limits }; internal static readonly GemBindingOptions Bindings = new() { MaxBindings = 16, MaxIdentifierBytes = 32, MaxTotalIdentifierBytes = 64 }; internal static readonly GemReportOptions Reports = new() { MaxEvents = 4, MaxReports = 4, MaxVariablesPerReport = 8, MaxReportsPerEvent = 4, MaxTotalVariables = 16, MaxTotalLinks = 16 }; internal static readonly GemDirectoryOptions Directories = new() { MaxEvents = 4, MaxVariablesPerEvent = 8, MaxTotalVariables = 16, MaxNameBytes = 64, MaxMetadataBytes = 128 }; internal static readonly HsmsWorkerOptions Worker = new() { CommandCapacity = 64, EventCapacity = 64, MaxCommandBytes = 131072, MaxEventBytes = 131072, PollIntervalMilliseconds = 1, ShutdownTimeoutMilliseconds = 2000 }; internal static HsmsOptions Transport(HsmsMode mode) => new() { Mode = mode, SessionId = 7, InitialSystemBytes = 1, T3Milliseconds = 2000, T5Milliseconds = 20, T6Milliseconds = 200, T7Milliseconds = 1000, T8Milliseconds = 500, ConnectTimeoutMilliseconds = 50, ShutdownTimeoutMilliseconds = 100, WriteTimeoutMilliseconds = 1000, MaxFrameBytes = 4110, MaxEvents = 128, MaxQueuedBytes = 65536, MaxWriteFrames = 64, MaxWriteBytes = 65536, MaxIoChunk = 3, MaxApplicationEvents = 128, MaxApplicationBytes = 65536, PollBudget = 32, ItemLimits = Limits }; internal static async Task<GemEquipmentWorker> StartEquipment(string address, HsmsMode mode, CancellationToken token) { using SecsItem temperature = SecsItem.FromF4([21.5f], Limits); using SecsItem count = SecsItem.FromU4([10], Limits); using SecsItem sample = SecsItem.FromAscii("CACHE", Limits); GemDataDefinition[] data = [new(1, "Count", "count", SecsFormat.U4, 1, 1, count), new(2, "Sample", "", SecsFormat.Ascii, 0, 16, sample), new(3, "Tree", "", SecsFormat.List, 0, 16, null), new(4, "Bytes", "", SecsFormat.Binary, 0, 16, null)]; GemReportBinding[] bindings = [new(201, GemDataSourceKind.Data, 1, null), new(202, GemDataSourceKind.Data, 2, null), new(203, GemDataSourceKind.Data, 3, null), new(204, GemDataSourceKind.Data, 4, null), new(211, GemDataSourceKind.Data, 1, null)]; return await GemEquipmentWorker.StartAsync(address, Transport(mode), Equipment, Worker, new GemStatusVariable[] { new(1, "Temperature", "C", temperature) }, new GemReportConfiguration(Reports, new uint[] { 100 }), null, null, null, new GemDataConfiguration(Data, data, Bindings, bindings), null, new GemDirectoryConfiguration(Directories, new GemEventDirectoryDefinition[] { new(100, "DataSnapshot", new uint[] { 201, 202, 211 }) }), token); }}C API에서는 pdcomm_gem_host_directory_request와 pdcomm_gem_equipment_configure_event_directory를 씁니다.
5. 보고서 조회
섹션 제목: “5. 보고서 조회”이벤트를 기다리지 않고 보고서 값을 바로 받고 싶을 때 호스트는 보고서 조회를 보냅니다.
| 요청 | GemReportQueryKind |
대상 | 응답 |
|---|---|---|---|
S6F15 |
Event |
CEID | S6F16: DATAID, CEID와 연결된 보고서들 |
S6F19 |
Individual |
RPTID | S6F20: 보고서 값 목록 |
S6F21 |
Annotated |
RPTID | S6F22: VID와 값의 쌍 목록 |
예제의 구성
섹션 제목: “예제의 구성”호스트는 RPTID 10 = VID [201, 202, 211, 201, 101, 301], RPTID 20 = VID [203, 204, 201]을 정의하고 CEID 100에 [20, 10] 순서로 연결합니다. 그다음 이벤트를 켜고 끄고 보고서와 연결을 지우면서 조회를 보내며, 모두 22단계를 실행합니다.
S6F15는 이벤트가 켜져 있고 보고서가 연결된 경우에만 보고서를 담습니다. 켜기 전, 끈 뒤, 연결을 지운 뒤에는 보고서가 없는 응답이 옵니다. PDComm 장비가 보내는 DATAID는 1입니다.- 없는 RPTID(999)를 조회하면 값이 없는 응답이 옵니다. 빈 응답만으로는 ID가 등록되었는지 알 수 없습니다.
- 장비는 조회에 자동 응답하지 않습니다.
Outcome이ReportQueryReceived인 결과를 받으면RespondReportQueryAsync로 응답합니다. 샘플을 주면 그 값을, 빈 목록이면 캐시를 씁니다. 예제는 일부 조회에만Count=77,Sample=SHOT샘플을 줍니다.
실행합니다
섹션 제목: “실행합니다”# Terminal 1dotnet "$Root\build\managed\bin\PDComm.GemQueriesDemo\release\PDComm.GemQueriesDemo.dll" $Native equipment passive 127.0.0.1:0# Terminal 2dotnet "$Root\build\managed\bin\PDComm.GemQueriesDemo\release\PDComm.GemQueriesDemo.dll" $Native host active 127.0.0.1:54473# Terminal 1dotnet "$PDCOMM_ROOT/build/managed/bin/PDComm.GemQueriesDemo/release/PDComm.GemQueriesDemo.dll" "$PDCOMM_NATIVE" equipment passive 127.0.0.1:0# Terminal 2dotnet "$PDCOMM_ROOT/build/managed/bin/PDComm.GemQueriesDemo/release/PDComm.GemQueriesDemo.dll" "$PDCOMM_NATIVE" host active 127.0.0.1:54473# Terminal 1dotnet "$PDCOMM_ROOT/build/managed/bin/PDComm.GemQueriesDemo/release/PDComm.GemQueriesDemo.dll" "$PDCOMM_NATIVE" equipment passive 127.0.0.1:0# Terminal 2dotnet "$PDCOMM_ROOT/build/managed/bin/PDComm.GemQueriesDemo/release/PDComm.GemQueriesDemo.dll" "$PDCOMM_NATIVE" host active 127.0.0.1:54473결과는 PASS host active와 PASS equipment passive입니다.
호스트는 RequestReportQueryAsync로 조회하고, 결과의 GetQueryInfo, GetQueryReport, GetQueryValue로 값을 읽습니다. S6F22의 값에만 VID가 붙어 있습니다.
전체 코드: PDComm.GemQueriesDemo/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.GemQueriesDemo <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;using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(30));await using GemWorker worker = role == "host" ? await Setup.StartHost(args[3], mode, cancellation.Token) : await Setup.StartEquipment(args[3], mode, cancellation.Token);if (mode == HsmsMode.Passive){ Console.WriteLine($"LISTEN {worker.LocalAddress ?? throw new InvalidOperationException("Missing passive address.")}"); Console.Out.Flush();}
await using IAsyncEnumerator<GemEvent> events = worker.ReadEventsAsync(cancellation.Token).GetAsyncEnumerator();if (worker is GemHostWorker host) await RunHost(host, events, cancellation.Token);else await RunEquipment((GemEquipmentWorker)worker, events, cancellation.Token);while (await events.MoveNextAsync()) events.Current.Dispose();await worker.Completion.WaitAsync(cancellation.Token);Console.WriteLine($"PASS {role} {args[2]}");return 0;
static async Task RunHost(GemHostWorker host, IAsyncEnumerator<GemEvent> events, CancellationToken cancellationToken){ await WaitSelected(host, cancellationToken); HsmsToken pending = await host.BeginCommunicationAsync(cancellationToken); using (GemEvent established = await Next(events, current => current.Result?.GetInfo().Token == pending)) { if (established.Result!.GetInfo().Outcome != GemOutcome.CommunicationEstablished) throw new InvalidOperationException("Communication establishment failed."); }
for (int step = 0; step < 22; step++) { pending = await Send(host, step, cancellationToken); using GemEvent response = await Next(events, current => current.Result?.GetInfo().Token == pending); Verify(response.Result!, step); } await host.StopAsync(cancellationToken);}
static async Task RunEquipment(GemEquipmentWorker equipment, IAsyncEnumerator<GemEvent> events, CancellationToken cancellationToken){ await WaitSelected(equipment, cancellationToken); int queryOrdinal = 0; while (await events.MoveNextAsync()) { using GemEvent current = events.Current; GemResult? result = current.Result; if (result is null) continue; GemResultInfo info = result.GetInfo(); if (info.Outcome == GemOutcome.ReportQueryReceived) { if (queryOrdinal >= 14) throw new InvalidOperationException("The equipment received an extra report query."); int step = QueryStep(queryOrdinal); GemQueryInfo query = result.GetQueryInfo(); if (!query.IsRequest || query.Responded || query.Kind != QueryKind(step) || query.Id != QueryId(step)) throw new InvalidOperationException("Unexpected equipment report query."); GemDataSample[] samples = CreateSamples(queryOrdinal); try { await equipment.RespondReportQueryAsync(result, samples, cancellationToken); } finally { foreach (GemDataSample sample in samples) sample.Value.Dispose(); } queryOrdinal++; } else if (info.Outcome == GemOutcome.StatusReplied) { if (queryOrdinal != 14) throw new InvalidOperationException("The equipment did not receive all report queries."); await equipment.StopAsync(cancellationToken); break; } }}
static GemDataSample[] CreateSamples(int ordinal){ if (ordinal is not (1 or 3 or 4)) return []; return [ new GemDataSample(1, SecsItem.FromU4([77], Setup.Limits)), new GemDataSample(2, SecsItem.FromAscii("SHOT", Setup.Limits)), ];}
static int QueryStep(int ordinal){ int[] steps = [2, 4, 5, 6, 7, 8, 9, 11, 12, 14, 15, 17, 19, 20]; return steps[ordinal];}
static Task<HsmsToken> Send(GemHostWorker host, int step, CancellationToken cancellationToken) => step switch{ 0 => host.DefineReportsAsync(1, new GemReportSpec[] { new(10, new uint[] { 201, 202, 211, 201, 101, 301 }), new(20, new uint[] { 203, 204, 201 }), }, cancellationToken), 1 => host.LinkReportsAsync(2, new GemLinkSpec[] { new(100, new uint[] { 20, 10 }) }, cancellationToken), 3 or 10 or 16 => host.EnableEventsAsync(step != 10, new uint[] { 100 }, cancellationToken), 13 => host.DefineReportsAsync(3, new GemReportSpec[] { new(20, Array.Empty<uint>()) }, cancellationToken), 18 => host.LinkReportsAsync(2, new GemLinkSpec[] { new(100, Array.Empty<uint>()) }, cancellationToken), 21 => host.RequestStatusValuesAsync(new uint[] { 1 }, cancellationToken), _ => host.RequestReportQueryAsync(QueryKind(step), QueryId(step), cancellationToken),};
static GemReportQueryKind QueryKind(int step) => step switch{ 2 or 4 or 9 or 11 or 15 or 17 or 19 => GemReportQueryKind.Event, 5 or 7 or 12 or 14 => GemReportQueryKind.Individual, 6 or 8 or 20 => GemReportQueryKind.Annotated, _ => throw new ArgumentOutOfRangeException(nameof(step)),};
static uint QueryId(int step){ if (step is 7 or 8 or 9) return 999; if (step is 12 or 14) return 20; return QueryKind(step) == GemReportQueryKind.Event ? 100u : 10u;}
static void Verify(GemResult result, int step){ GemResultInfo generic = result.GetInfo(); if (step is 0 or 1 or 3 or 10 or 13 or 16 or 18) { GemRequestKind expectedKind = step is 0 or 13 ? GemRequestKind.DefineReports : step is 1 or 18 ? GemRequestKind.LinkReports : GemRequestKind.EnableEvents; GemOutcome expectedOutcome = step is 0 or 13 ? GemOutcome.DefineReportsAck : step is 1 or 18 ? GemOutcome.LinkReportsAck : GemOutcome.EnableEventsAck; if (generic.Kind != GemResultKind.Response || generic.RequestKind != expectedKind || generic.Outcome != expectedOutcome || generic.Acknowledgement != 0) throw new InvalidOperationException("Unexpected report configuration response."); return; } if (step == 21) { if (generic.Kind != GemResultKind.Response || generic.RequestKind != GemRequestKind.StatusValues || generic.Outcome != GemOutcome.ValuesReceived || generic.EntryCount != 1) throw new InvalidOperationException("Unexpected status response metadata."); using GemStatusValue value = result.GetStatusValue(0); if (value.Id != 1 || value.Value.GetF4()[0] != 21.5f) throw new InvalidOperationException("Unexpected status response value."); return; }
VerifyQuery(result, generic, step);}
static void VerifyQuery(GemResult result, GemResultInfo generic, int step){ GemReportQueryKind kind = QueryKind(step); GemOutcome expectedOutcome = kind switch { GemReportQueryKind.Event => GemOutcome.EventQueryReceived, GemReportQueryKind.Individual => GemOutcome.IndividualQueryReceived, GemReportQueryKind.Annotated => GemOutcome.AnnotatedQueryReceived, _ => throw new ArgumentOutOfRangeException(nameof(kind)), }; GemQueryInfo query = result.GetQueryInfo(); if (generic.Kind != GemResultKind.Response || generic.Outcome != expectedOutcome || generic.RequestKind != (GemRequestKind)kind || generic.Acknowledgement is not null || query.IsRequest || query.Responded || query.Kind != kind || query.Id != QueryId(step)) throw new InvalidOperationException("Unexpected report query response metadata.");
if (kind == GemReportQueryKind.Event) { int reportCount = step == 4 ? 2 : step == 17 ? 1 : 0; int valueCount = step == 4 ? 9 : step == 17 ? 6 : 0; if (!query.DataIdPresent || query.DataId != 1 || query.ReportCount != (nuint)reportCount || query.ValueCount != (nuint)valueCount || generic.EntryCount != (nuint)reportCount) throw new InvalidOperationException("Unexpected event query shape."); for (nuint reportIndex = 0; reportIndex < query.ReportCount; reportIndex++) { uint reportId = step == 4 && reportIndex == 0 ? 20u : 10u; GemQueryReportInfo report = result.GetQueryReport(reportIndex); nuint expectedValues = reportId == 10 ? 6u : 3u; if (report.Id != reportId || report.ValueCount != expectedValues) throw new InvalidOperationException("Unexpected event query report."); VerifyValues(result, reportIndex, reportId, report.ValueCount, step == 4, false); } return; }
int count = step is 5 or 6 or 20 ? 6 : step == 12 ? 3 : 0; if (query.DataIdPresent || query.ReportCount != 0 || query.ValueCount != (nuint)count || generic.EntryCount != (nuint)count) throw new InvalidOperationException("Unexpected individual query shape."); VerifyValues(result, 0, QueryId(step), query.ValueCount, step == 6, kind == GemReportQueryKind.Annotated);}
static void VerifyValues(GemResult result, nuint reportIndex, uint reportId, nuint count, bool sampled, bool annotated){ for (nuint index = 0; index < count; index++) { uint variableId = VariableId(reportId, (int)index); using GemQueryValue actual = result.GetQueryValue(reportIndex, index); if (actual.VariableId != (annotated ? variableId : null)) throw new InvalidOperationException("Unexpected annotated variable identifier."); using SecsItem expected = ExpectedValue(variableId, sampled); if (!actual.Value.Encode().SequenceEqual(expected.Encode())) throw new InvalidOperationException("Unexpected report query value."); }}
static uint VariableId(uint reportId, int index){ uint[] report10 = [201, 202, 211, 201, 101, 301]; uint[] report20 = [203, 204, 201]; return reportId == 10 ? report10[index] : report20[index];}
static SecsItem ExpectedValue(uint variableId, bool sampled) => variableId switch{ 201 or 211 => SecsItem.FromU4([sampled ? 77u : 10u], Setup.Limits), 202 => SecsItem.FromAscii(sampled ? "SHOT" : "CACHED", Setup.Limits), 203 => Setup.CreateTree(), 204 => SecsItem.FromBinary([], Setup.Limits), 101 => SecsItem.FromF4([21.5f], Setup.Limits), 301 => SecsItem.FromU4([10], Setup.Limits), _ => throw new ArgumentOutOfRangeException(nameof(variableId)),};
static async Task<GemEvent> Next(IAsyncEnumerator<GemEvent> events, Func<GemEvent, bool> predicate){ while (await events.MoveNextAsync()) { if (predicate(events.Current)) return events.Current; events.Current.Dispose(); } throw new InvalidOperationException("Event stream ended early.");}
static async Task WaitSelected(GemWorker worker, CancellationToken cancellationToken){ while ((await worker.GetTransportStateAsync(cancellationToken)).State != HsmsConnectionState.Selected) await Task.Delay(1, cancellationToken);}
internal static class Setup{ internal static readonly SecsLimits Limits = new(4096, 4096, 256, 16); private static readonly GemHostOptions HostOptions = new() { MaxPendingIds = 64, ItemLimits = Limits, }; private static readonly GemEquipmentOptions EquipmentOptions = new() { Model = "PDComm", SoftwareRevision = "0.1.0", InitialControl = GemControlState.HostOffline, OnlineMode = GemOnlineMode.Remote, MaxVariables = 8, MaxMetadataBytes = 1024, MaxValueBytes = 4096, ItemLimits = Limits, }; private static readonly GemReportOptions ReportOptions = new() { MaxEvents = 4, MaxReports = 4, MaxVariablesPerReport = 8, MaxReportsPerEvent = 4, MaxTotalVariables = 16, MaxTotalLinks = 16, }; private static readonly GemDataOptions DataOptions = new() { MaxVariables = 8, MaxMetadataBytes = 512, MaxValueBytes = 4096, MaxSamplesPerEvent = 5, MaxSampleBytes = 4096, ItemLimits = Limits, }; private static readonly GemBindingOptions BindingOptions = new() { MaxBindings = 16, MaxIdentifierBytes = 32, MaxTotalIdentifierBytes = 64, }; private static readonly GemConstantOptions ConstantOptions = new() { MaxConstants = 8, MaxMetadataBytes = 1024, MaxValueBytes = 4096, MaxQueryIds = 32, MaxChangesPerRequest = 16, MaxIdentifierBytes = 32, ItemLimits = Limits, }; private static readonly HsmsWorkerOptions WorkerOptions = new() { CommandCapacity = 64, EventCapacity = 64, MaxCommandBytes = 131072, MaxEventBytes = 131072, PollIntervalMilliseconds = 1, ShutdownTimeoutMilliseconds = 2000, };
internal static Task<GemHostWorker> StartHost(string address, HsmsMode mode, CancellationToken cancellationToken) => GemHostWorker.StartAsync( address, Transport(mode), HostOptions, WorkerOptions, cancellationToken);
internal static async Task<GemEquipmentWorker> StartEquipment(string address, HsmsMode mode, CancellationToken cancellationToken) { using SecsItem temperature = SecsItem.FromF4([21.5f], Limits); using SecsItem count = SecsItem.FromU4([10], Limits); using SecsItem cached = SecsItem.FromAscii("CACHED", Limits); using SecsItem tree = CreateTree(); using SecsItem bytes = SecsItem.FromBinary([], Limits); using SecsItem constantId = SecsItem.FromU1([1], Limits); using SecsItem constantValue = SecsItem.FromU4([10], Limits);
GemDataDefinition[] data = [ new(1, "Count", "count", SecsFormat.U4, 1, 1, count), new(2, "Sample", "", SecsFormat.Ascii, 0, 32, cached), new(3, "Tree", "", SecsFormat.List, 0, 4, tree), new(4, "Bytes", "", SecsFormat.Binary, 0, 32, bytes), ]; GemReportBinding[] bindings = [ new(201, GemDataSourceKind.Data, 1, null), new(202, GemDataSourceKind.Data, 2, null), new(203, GemDataSourceKind.Data, 3, null), new(204, GemDataSourceKind.Data, 4, null), new(211, GemDataSourceKind.Data, 1, null), new(101, GemDataSourceKind.Status, 1, null), new(301, GemDataSourceKind.Constant, 0, constantId), ]; GemConstantDefinition[] constants = [ new(constantId, "CycleLimit", "count", constantValue, null, null, null, 1, 1), ]; return await GemEquipmentWorker.StartAsync( address, Transport(mode), EquipmentOptions, WorkerOptions, [new GemStatusVariable(1, "Temperature", "C", temperature)], new GemReportConfiguration(ReportOptions, new uint[] { 100 }), null, null, null, new GemDataConfiguration(DataOptions, data, BindingOptions, bindings), new GemConstantConfiguration(ConstantOptions, constants), cancellationToken); }
internal static SecsItem CreateTree() { using SecsItem number = SecsItem.FromU4([7], Limits); using SecsItem text = SecsItem.FromAscii("NESTED", Limits); return SecsItem.CreateList([number, text], Limits); }
private 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, };}C API에서는 pdcomm_gem_host_report_query와 pdcomm_gem_equipment_respond_report_query를 씁니다.
Qt 예제에 대해
섹션 제목: “Qt 예제에 대해”examples/qt의 data.cpp, constants.cpp, directories.cpp, queries.cpp는 같은 시나리오를 Qt로 구현하지만, PDComm 제품 시험의 검증 코드를 함께 쓰므로 예제 폴더만으로는 빌드되지 않습니다. Qt 애플리케이션에서 PDComm 끝점을 다루는 구조는 보고서와 이벤트의 reports.cpp로 익힙니다. C++ API는 GemEquipment::configureDataVariables, GemEquipment::configureReportBindings, GemEquipment::configureConstants, GemEquipment::configureEventDirectory, GemHost::dataDirectory, GemHost::eventDirectory, GemHost::eventQuery처럼 .NET API와 같은 이름으로 대응합니다.
시뮬레이터로 확인합니다
섹션 제목: “시뮬레이터로 확인합니다”상태 변수와 데이터 변수
- 장비와 호스트 창에서 세션을 시작하고 통신을 수립합니다. 프로필을 적용하지 않은 시뮬레이터 장비에는 SVID 1001
EquipmentState(ASCIIIDLE)와 1002WaferCount(U4 0)가 있습니다. - 호스트 창의 Status & Data에서 Status IDs에
1001, 1002를 넣고 Discover names를 누른 다음 Read values를 누릅니다. 이름을 먼저 읽어야 값을 읽을 수 있습니다. - 장비 창의 Status & Data에서 SVID 1002를 고르고 Value에 새 값을 넣어 Apply value를 누릅니다. 호스트에서 다시 Read values를 누르면 바뀐 값이 보입니다.




장비 상수
- 장비 창의 Equipment Constants 작업공간 Configuration 탭에서 Load example을 누르고 Reserve for next start로 예약한 뒤 세션을 시작합니다. 예제 상수는 ECID
U1(1)CycleLimit(U4 10, 범위 1–100)입니다. - 호스트 창의 Host Requests 탭에서 Values 또는 Metadata를 고르고 Query를 누릅니다. 값을 바꾸려면 아래 칸에
ID=value형식으로 넣고 Send change를 누릅니다. - 장비 창의 Equipment Traffic 탭에서 받은 요청을 고르고 Apply (0), Cannot perform (2), Out of range (3), Reject (4) 중 하나를 골라 Respond를 누릅니다.




데이터 디렉터리는 Status & Data의 Read data directory로, 이벤트 디렉터리와 보고서 조회는 Reports & Events 작업공간 Traffic 탭의 Directories, Queries에서 실행합니다.
자주 만나는 오류
섹션 제목: “자주 만나는 오류”| 증상 | 원인 | 해결 |
|---|---|---|
요청이 PdcommException(InvalidState)으로 실패합니다. |
통신 수립 전에 요청했습니다. | CommunicationEstablished를 받은 뒤 보냅니다. |
S1F4의 값이 길이 0인 항목입니다. |
장비에 없는 SVID를 요청했습니다. | 장비가 등록한 ID를 S1F11로 확인합니다. |
| 보고서에 기대한 데이터 변수 값이 없고 이벤트 보고가 실패합니다. | 캐시가 없는 DV를 샘플 없이 보고했습니다. | UpdateDataVariableAsync로 캐시를 채우거나 샘플을 함께 넘깁니다. |
S2F34가 DRACK 4로 보고서 정의를 거부합니다. |
장비에 없는 VID를 썼습니다. 바인딩을 설치한 장비에서는 바인딩 표에 없는 VID입니다. | 바인딩 표에 있는 VID만 씁니다. |
S2F16 EAC 3 |
형식, 범위, 길이가 맞지 않습니다. | S2F29의 최솟값·최댓값과 현재 값의 형식을 확인합니다. |
| 장비 상수를 바꿨는데 값이 그대로입니다. | 장비 애플리케이션이 Apply 외의 결정을 보냈거나 검사에서 거부되었습니다. |
응답의 EAC와 Applied를 확인합니다. |
호스트가 S2F16이나 S6F16을 받지 못하고 T3 뒤에 실패합니다. |
장비 애플리케이션이 RespondConstantsAsync나 RespondReportQueryAsync를 호출하지 않았습니다. |
두 요청은 자동 응답하지 않으므로 반드시 응답합니다. |
- 예제는 시나리오를 마치면 스스로 끝납니다. 멈춘 프로그램은 Ctrl+C로 끝냅니다.
- 필요 없으면
$PDCOMM_ROOT/build/managed를 지웁니다.