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

데이터 수집

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절을 읽기 쉽습니다.
터미널 창
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"
}

각 예제마다 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 1
dotnet "$Root\build\managed\bin\PDComm.GemDataDemo\release\PDComm.GemDataDemo.dll" $Native equipment passive 127.0.0.1:0
# Terminal 2
dotnet "$Root\build\managed\bin\PDComm.GemDataDemo\release\PDComm.GemDataDemo.dll" $Native host active 127.0.0.1:54459

장비는 LISTEN 127.0.0.1:<port>를 출력한 뒤 기다리고, 호스트가 끝나면 두 프로그램이 각각 다음 줄을 출력하고 종료 코드 0으로 끝납니다.

PASS host active
PASS equipment passive
  • PublishEventWithDataAsync는 GemDataSample(내부 DV ID와 값)를 받습니다. 값을 정하는 순서는 샘플, 캐시, 실패입니다. 샘플은 이 보고서에만 쓰이고 캐시를 바꾸지 않습니다.
  • 보고서에 쓰이지 않는 샘플도 개수, 형식, 크기를 모두 검사합니다. 예제의 DV 5 Unused 샘플이 그 경우입니다.
  • 캐시가 없는(지워진) 데이터 변수를 샘플 없이 보고하면 보고 전체가 실패합니다(InvalidState). 캐시 없음은 빈 값과 다릅니다.
  • 샘플 목록이 비어 있으면 PublishEventWithDataAsync는 PublishEventAsync와 같습니다. 이때는 데이터 변수 구성(GemDataConfiguration)이 없어도 됩니다. 샘플을 하나라도 주려면 데이터 변수 구성이 있어야 하며, 없으면 InvalidState로 실패합니다.
  • 보고할 이벤트는 켜져 있어야 합니다. 켜진 이벤트에 연결된 보고서가 없으면 장비는 보고서 목록이 빈 S6F11을 보냅니다.
  • UpdateDataVariableAsync와 ClearDataVariableAsync가 캐시를 바꿉니다. SDK는 값을 스스로 수집하지 않습니다. 장치에서 값을 읽어 넣는 일은 애플리케이션이 합니다.
전체 코드: PDComm.GemDataDemo/Program.cs
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을 씁니다.

장비 상수(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 1
dotnet "$Root\build\managed\bin\PDComm.GemConstantsDemo\release\PDComm.GemConstantsDemo.dll" $Native equipment passive 127.0.0.1:0
# Terminal 2
dotnet "$Root\build\managed\bin\PDComm.GemConstantsDemo\release\PDComm.GemConstantsDemo.dll" $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
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을 씁니다.

디렉터리(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 1
dotnet "$Root\build\managed\bin\PDComm.GemDirectoriesDemo\release\PDComm.GemDirectoriesDemo.dll" $Native equipment passive 127.0.0.1:0
# Terminal 2
dotnet "$Root\build\managed\bin\PDComm.GemDirectoriesDemo\release\PDComm.GemDirectoriesDemo.dll" $Native host active 127.0.0.1:54468

결과는 PASS host active와 PASS equipment passive입니다.

호스트는 RequestDirectoryAsync에 GemDirectoryKind(Data 또는 Event)를 넘깁니다. 장비 쪽은 자동으로 응답하므로 따로 처리할 코드가 없습니다. 이벤트 디렉터리는 GemDirectoryConfiguration로 시작 전에 설치합니다.

전체 코드: PDComm.GemDirectoriesDemo/Program.cs
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를 씁니다.

이벤트를 기다리지 않고 보고서 값을 바로 받고 싶을 때 호스트는 보고서 조회를 보냅니다.

요청 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 1
dotnet "$Root\build\managed\bin\PDComm.GemQueriesDemo\release\PDComm.GemQueriesDemo.dll" $Native equipment passive 127.0.0.1:0
# Terminal 2
dotnet "$Root\build\managed\bin\PDComm.GemQueriesDemo\release\PDComm.GemQueriesDemo.dll" $Native host active 127.0.0.1:54473

결과는 PASS host active와 PASS equipment passive입니다.

호스트는 RequestReportQueryAsync로 조회하고, 결과의 GetQueryInfo, GetQueryReport, GetQueryValue로 값을 읽습니다. S6F22의 값에만 VID가 붙어 있습니다.

전체 코드: PDComm.GemQueriesDemo/Program.cs
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를 씁니다.

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와 같은 이름으로 대응합니다.

상태 변수와 데이터 변수

  1. 장비와 호스트 창에서 세션을 시작하고 통신을 수립합니다. 프로필을 적용하지 않은 시뮬레이터 장비에는 SVID 1001 EquipmentState(ASCII IDLE)와 1002 WaferCount(U4 0)가 있습니다.
  2. 호스트 창의 Status & Data에서 Status IDs에 1001, 1002를 넣고 Discover names를 누른 다음 Read values를 누릅니다. 이름을 먼저 읽어야 값을 읽을 수 있습니다.
  3. 장비 창의 Status & Data에서 SVID 1002를 고르고 Value에 새 값을 넣어 Apply value를 누릅니다. 호스트에서 다시 Read values를 누르면 바뀐 값이 보입니다.
호스트의 Status & Data 작업공간. 장비에서 읽은 SVID 1001 EquipmentState(IDLE)와 1002 WaferCount(0)가 Received 상태로 보입니다.호스트의 Status & Data 작업공간. 장비에서 읽은 SVID 1001 EquipmentState(IDLE)와 1002 WaferCount(0)가 Received 상태로 보입니다.
장비의 Status & Data 작업공간. 로컬 상태 변수 1001과 1002가 Local 상태로 보입니다.장비의 Status & Data 작업공간. 로컬 상태 변수 1001과 1002가 Local 상태로 보입니다.

장비 상수

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

데이터 디렉터리는 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를 지웁니다.