היסטוריית עדכונים (גרסה ראשונה, פורסמה בתאריך 1 Nov 2026)
- פרסום ראשון
לצטט את המאמר הזה(DOI: 10.5281/zenodo.23968388)
מאמר זה מאוחסן בארכיון Zenodo. להלן גם ה-DOI שתמיד מפנה לגרסה האחרונה וגם ה-DOI המקובע לגרסה שאתם קוראים.
Go Komura (2026). מבוא ל-OPC UA ── איסוף נתונים מ-PLC ומציוד באפליקציית Windows. KomuraSoft LLC. https://doi.org/10.5281/zenodo.23968388 https://comcomponent.com/he/blog/opc-ua-windows-data-collection/
- DOI (הגרסה האחרונה)
- 10.5281/zenodo.23968388
- DOI (הגרסה הזו)
- 10.5281/zenodo.23968389
כשאוספים נתונים מ-PLC או מציוד לתוך אפליקציית Windows, ולציוד יש OPC UA server, לפעמים אפשר לחסוך את הצורך לכתוב תקשורת נפרדת לכל יצרן. אבל לא מספיק להתחבר ולקרוא ערכים. בלי להכיר את ה-status וה-timestamps שחוזרים עם כל ערך, את ה-subscription שמודיע על שינויים, את אישור החיבור באמצעות certificate ואת ההתאוששות מניתוק, אי אפשר יהיה לסמוך אחר כך על הנתונים שנאספו.
המאמר מסביר את החלקים של OPC UA שצריך לאיסוף נתונים, יחד עם תוצאות של הרצה אמיתית של client ב-C#. ה-client משתמש בספרייה של OPC Foundation ל-.NET, ובצד השני יש שרת שמדמה ציוד. לא בדקתי מול PLC או ציוד מסוים. את ההגדרות של כל ציוד בודקים בתיעוד שלו.
איסוף תמונות ממצלמות GigE Vision ו-USB3 Vision מוסבר ב-“Dropped Frames with GigE Vision and USB3 Vision Cameras”, וציוד שמתקשר ב-serial ב-“Pitfalls באפליקציות serial communication”.
ההבדל בין OPC UA ל-OPC Classic
המפרטים של OPC Classic מעבירים נתונים בין רכיבי תוכנה באמצעות COM/DCOM של Windows. OPC DA לנתוני תהליך, OPC A&E להתראות ו-OPC HDA להיסטוריה מוגדרים כמפרטים נפרדים1.
ב-2008 פרסם OPC Foundation את OPC UA (Unified Architecture). OPC UA הוא ארכיטקטורה מוכוונת שירותים שלא תלויה בפלטפורמה מסוימת, והיא מאחדת את הפונקציות של כל אחד ממפרטי Classic. בין הסיבות לבנייה מחדש, OPC Foundation מציין ש-Microsoft התחילה להעדיף מנגנונים cross-platform על פני COM/DCOM, שהיה צורך לרוץ על מערכות שאינן של Microsoft ועל מכשירים embedded, ושהיה צורך להתחבר בצורה מאובטחת דרך firewalls1.
המפרט של OPC UA הוא גם תקן של IEC בסדרה IEC 625412. לגבי ה-port, ב-IANA רשום 4840/TCP בשם opcua-tcp3.
flowchart TB
accTitle: ההבדל בין OPC Classic ל-OPC UA
accDescr: תרשים שמראה ש-OPC Classic בנוי על COM ו-DCOM של Windows ומחולק למפרטים נפרדים DA, A&E ו-HDA, ואילו OPC UA מאחד את הפונקציות שלהם במפרט אחד שלא תלוי בפלטפורמה.
C["OPC Classic"] --> D["רץ על COM/DCOM (Windows)"]
D --> DA["מפרטים נפרדים: DA, A&E, HDA"]
DA -->|"מאחד את הפונקציות"| U["OPC UA (2008)"]
U --> P["לא תלוי בפלטפורמה מסוימת"]
P --> S["תקן IEC 62541"]
איור 1: OPC Classic הוא מפרטים נפרדים מעל COM/DCOM, ו-OPC UA מאחד אותם למפרט אחד.
אם לציוד קיים יש רק OPC DA server, צריך בצד ה-client מנגנון שמשתמש ב-COM/DCOM. המאמר עוסק רק ב-OPC UA.
ב-diagram, solid line מציינת relation שתמיד מתקיים ו-dashed line מציינת relation מותנה (התנאים מופיעים בהסבר של כל relation ב-detail page). הרשימה המלאה של ה-relations (סה”כ 13, כולל evidence ו-certainty) וההגדרות של ה-concepts המרכזיים נמצאות ב-detail page של ה-knowledge map (ביפנית). Data: JSON-LD / Turtle
הסביבה שבדקתי
זו הסביבה שהרצתי.
| תפקיד | במה השתמשתי |
|---|---|
| Client | C#, .NET 10 SDK 10.0.114, חבילת NuGet בשם OPCFoundation.NetStandard.Opc.Ua.Client 2.0.1 |
| שרת | ציוד מדומה שנבנה עם Python 3.13 ו-asyncua 2.1.0 (port 4840 באותו מחשב) |
| OS | Linux (לא בדקתי הרצה על Windows) |
ספריית ה-client היא המימוש ל-.NET ש-OPC Foundation מפרסם. לגבי השימוש בגרסאות 2.0, הלכתי לפי הדרך שמתוארת ב-Getting started באותו repository4.
flowchart TB
accTitle: הסביבה שבדקתי
accDescr: תרשים שמראה client ב-C# שמשתמש בספרייה של OPC Foundation ל-.NET ומתחבר בחיבור מוצפן לשרת asyncua שמדמה ציוד ומאזין ב-port 4840.
A["Client ב-C# (LineCollector)"] -->|"opc.tcp, SignAndEncrypt"| B["שרת ציוד מדומה (asyncua)"]
A -.-> L["הספרייה של OPC Foundation ל-.NET 2.0.1"]
B -.-> V["ארבעה משתנים תחת Line1"]
איור 2: באותו מחשב, ה-client ב-C# התחבר לשרת הציוד המדומה.
שרת הציוד המדומה
במקום ציוד אמיתי, בניתי שרת קטן עם asyncua5. תחת object בשם Line1 יש ארבעה משתנים: טמפרטורה (Temperature), מספר יחידות שיוצרו (PartCount), האם פועל (Running) וערך יעד (Setpoint). הטמפרטורה משתנה כל 0.5 שניות, ומספר היחידות כל 2 שניות. לאבטחה הגדרתי שתי אפשרויות: בלי הצפנה (None), וחתימה והצפנה (SignAndEncrypt של Basic256Sha256).
# ציוד מדומה לבדיקות. הוא לא מחליף ציוד או PLC ב-production.
import asyncio, math, random, socket
from pathlib import Path
from cryptography.x509.oid import ExtendedKeyUsageOID
from asyncua import Server, ua
from asyncua.crypto.cert_gen import setup_self_signed_certificate
HERE = Path(__file__).parent
APP_URI = "urn:komurasoft:demo-plc"
async def main():
key, cert = HERE / "server_key.pem", HERE / "server_cert.der"
await setup_self_signed_certificate(
key, cert, APP_URI, socket.gethostname(),
[ExtendedKeyUsageOID.SERVER_AUTH], {"commonName": "DemoPlcServer"})
server = Server()
await server.init()
server.set_endpoint("opc.tcp://0.0.0.0:4840/demo/")
server.set_server_name("DemoPlcServer")
await server.set_application_uri(APP_URI)
server.set_security_policy([
ua.SecurityPolicyType.NoSecurity,
ua.SecurityPolicyType.Basic256Sha256_SignAndEncrypt,
])
await server.load_certificate(str(cert))
await server.load_private_key(str(key))
ns = await server.register_namespace("urn:komurasoft:demo-plc:line1")
line1 = await server.nodes.objects.add_object(ua.NodeId("Line1", ns), "Line1")
temperature = await line1.add_variable(ua.NodeId("Line1.Temperature", ns), "Temperature", 25.0)
part_count = await line1.add_variable(
ua.NodeId("Line1.PartCount", ns), "PartCount", ua.Variant(0, ua.VariantType.UInt32))
await line1.add_variable(ua.NodeId("Line1.Running", ns), "Running", True)
setpoint = await line1.add_variable(ua.NodeId("Line1.Setpoint", ns), "Setpoint", 30.0)
await setpoint.set_writable()
async with server:
print("listening", flush=True)
t, count = 0, 0
while True:
await asyncio.sleep(0.5)
t += 1
value = 25.0 + 2.0 * math.sin(t / 4) + random.uniform(-0.05, 0.05)
await temperature.write_value(round(value, 2))
if t % 4 == 0:
count += 1
await part_count.write_value(ua.Variant(count, ua.VariantType.UInt32))
asyncio.run(main())
השרת הזה מקבל את ה-certificates של ה-clients שמתחברים אליו בלי לבדוק אותם. זו הגדרה לבדיקות, והיא שונה מההתנהגות של ציוד ב-production.
ה-client ב-C#
בקובץ הפרויקט הוספתי את שתי החבילות האלה.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="2.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
</ItemGroup>
</Project>
ה-client מתחבר, עושה browse, קורא, כותב ונרשם ל-subscription, בסדר הזה. הסעיפים הבאים מסבירים מה כל חלק עושה.
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Client.Subscriptions;
using Opc.Ua.Client.Subscriptions.Streaming;
using MonitoredItemOptions = Opc.Ua.Client.Subscriptions.MonitoredItems.MonitoredItemOptions;
string url = args.Length > 0 ? args[0] : "opc.tcp://localhost:4840/demo/";
int notifications = args.Length > 1 ? int.Parse(args[1]) : 6;
const string LineNamespace = "urn:komurasoft:demo-plc:line1";
HostApplicationBuilder builder = Host.CreateApplicationBuilder();
builder.Logging.SetMinimumLevel(LogLevel.Warning);
builder.Services
.AddOpcUa()
.AddClient(options =>
{
options.ApplicationName = "LineCollector";
options.ApplicationUri = "urn:localhost:LineCollector";
options.ProductUri = "urn:komurasoft:LineCollector";
options.PkiRoot = Path.Combine(AppContext.BaseDirectory, "pki");
})
.AddDiscoveryAndConnect(options =>
{
options.DiscoveryUrl = url;
options.SecurityMode = MessageSecurityMode.SignAndEncrypt;
options.SecurityPolicyUri = SecurityPolicies.Basic256Sha256;
});
using IHost host = builder.Build();
await host.StartAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(2));
CancellationToken ct = timeout.Token;
try
{
var connect = host.Services
.GetRequiredService<Func<CancellationToken, Task<ManagedSession>>>();
ManagedSession session = await connect(ct);
await using (session)
{
Console.WriteLine($"接続: {session.ConfiguredEndpoint?.EndpointUrl}");
// השרת קובע את ה-namespace index. מוצאים אותו לפי ה-URI ובונים ממנו את ה-NodeId
ushort ns = (ushort)session.NamespaceUris.GetIndex(LineNamespace);
var line1 = new NodeId("Line1", ns);
var temperature = new NodeId("Line1.Temperature", ns);
var partCount = new NodeId("Line1.PartCount", ns);
var setpoint = new NodeId("Line1.Setpoint", ns);
// Browse: מציגים את המשתנים שתחת Line1
var browser = new Browser(session)
{
BrowseDirection = BrowseDirection.Forward,
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
IncludeSubtypes = true,
NodeClassMask = (uint)NodeClass.Variable,
};
Console.WriteLine("Line1 の変数:");
foreach (ReferenceDescription child in await browser.BrowseAsync(line1, ct))
{
Console.WriteLine($" {child.DisplayName} {child.NodeId}");
}
// קריאה: בודקים לא רק את הערך, אלא גם את ה-status ואת שני ה-timestamps
DataValue t = await session.ReadValueAsync(temperature, ct);
Console.WriteLine($"Temperature = {t.WrappedValue} 状態 {t.StatusCode}");
Console.WriteLine($" Source {Hms(t.SourceTimestamp)} / Server {Hms(t.ServerTimestamp)}");
// קריאה של node שלא קיים מחזירה status מסוג Bad במקום ערך
try
{
await session.ReadValueAsync(new NodeId("Line1.Pressure", ns), ct);
}
catch (ServiceResultException ex)
{
Console.WriteLine($"Line1.Pressure の読み取り: {ex.StatusCode}");
}
// כתיבה: משנים את Setpoint ל-32.5
var write = new WriteValue
{
NodeId = setpoint,
AttributeId = Attributes.Value,
Value = new DataValue(new Variant(32.5)),
};
WriteResponse wr = await session.WriteAsync(null, new[] { write }, ct);
Console.WriteLine($"Setpoint の書き込み: {wr.Results[0]}");
// Subscription: מבקשים מהשרת sampling כל 500ms ושיודיע על שינויים
Console.WriteLine($"購読({notifications}件まで):");
await foreach (DataValueChange change in session.DefaultStreaming
.SubscribeDataChangesAsync(new[] { temperature, partCount }, options: new MonitoredItemOptions
{
SamplingInterval = TimeSpan.FromMilliseconds(500),
QueueSize = 5,
}, ct: ct)
.TakeAsync(notifications)
.WithCancellation(ct))
{
DataValue v = change.Value;
string name = change.MonitoredItem?.Name.Split("s=")[^1] ?? "?";
Console.WriteLine($" {Hms(v.SourceTimestamp)} {name,-17} {v.WrappedValue,6} {v.StatusCode}");
}
}
}
catch (ServiceResultException ex)
{
Console.Error.WriteLine($"OPC UA エラー {ex.StatusCode}: {ex.Message}");
Environment.ExitCode = 1;
}
finally
{
await host.StopAsync();
}
static string Hms(DateTimeUtc time) => time.ToDateTime().ToString("HH:mm:ss.fff");
בהרצה הראשונה לא הצלחתי להתחבר. הסיבה מוסברת בסעיף “אישור חיבורים באמצעות certificates”. זה הפלט אחרי שה-client סמך על ה-certificate של השרת. השמטתי את ה-warning logs של הספרייה.
接続: opc.tcp://localhost:4840/demo/
Line1 の変数:
Temperature ns=2;s=Line1.Temperature
PartCount ns=2;s=Line1.PartCount
Running ns=2;s=Line1.Running
Setpoint ns=2;s=Line1.Setpoint
Temperature = 23.09 状態 Good [0x00000000]
Source 13:40:51.302 / Server 13:40:51.302
Line1.Pressure の読み取り: BadNodeIdUnknown [0x80340000]
Setpoint の書き込み: Good [0x00000000]
購読(6件まで):
13:40:51.302 Line1.Temperature 23.09 Good [0x00000000]
13:40:50.801 Line1.PartCount 67 Good [0x00000000]
13:40:51.802 Line1.Temperature 23.05 Good [0x00000000]
13:40:52.304 Line1.Temperature 23.07 Good [0x00000000]
13:40:52.806 Line1.Temperature 23.2 Good [0x00000000]
13:40:52.806 Line1.PartCount 68 Good [0x00000000]
השעות הן ב-UTC.
נתוני הציוד מופיעים כ-nodes ב-address space
שרת OPC UA חושף את הנתונים שלו כאוסף של nodes. האוסף הזה נקרא address space. יש כמה סוגים של nodes, למשל objects שמייצגים תיקיות או ציוד, ו-variables שמחזיקים ערכים, וה-nodes מחוברים זה לזה ב-references. ה-client עוקב אחרי ה-references עם שירות Browse כדי לגלות אילו nodes קיימים6.
בפלט שלמעלה ה-client עוקב אחרי ה-hierarchical references מ-Line1 ומוצא ארבעה משתנים.
flowchart TB
accTitle: ה-address space של הציוד המדומה
accDescr: תרשים שמראה object בשם Line1 תחת התיקייה Objects, ותחתיו ארבעה משתנים: Temperature, PartCount, Running ו-Setpoint.
O["Objects"] --> L["Line1 (object)"]
L --> T["Temperature (משתנה)"]
L --> P["PartCount (משתנה)"]
L --> R["Running (משתנה)"]
L --> S["Setpoint (משתנה, אפשר לכתוב)"]
איור 3: ה-address space של הציוד המדומה. ה-client מוצא את ה-nodes על ידי מעקב אחרי ה-references עם Browse.
NodeId מורכב מ-namespace ומ-identifier
מזהים nodes לפי NodeId. בפלט, ns=2;s=Line1.Temperature אומר שה-namespace index הוא 2 וה-identifier הוא המחרוזת Line1.Temperature.
בפועל, namespace הוא URI. המפרט קובע שבתוך השירותים, ה-URI של ה-namespace מיוצג כמספר, ושהמספר הזה הוא המיקום ב-NamespaceArray של השרת. Index 0 הוא ה-namespace של OPC UA, http://opcfoundation.org/UA/7.
לכן לא כותבים את המספר של ה-index בקוד, אלא מוצאים אותו לפי ה-URI. הקוד שלמעלה משתמש ב-session.NamespaceUris.GetIndex(LineNamespace) כדי לקבל 2 מה-URI, ובונה ממנו את ה-NodeId.
flowchart TB
accTitle: בניית NodeId מה-URI של ה-namespace
accDescr: תרשים שמראה שה-client מחפש את ה-URI של ה-namespace ב-NamespaceArray של השרת כדי לקבל את ה-index, ומשלב את ה-index עם ה-identifier כדי לבנות NodeId.
U["ה-URI של ה-namespace"] --> A["ה-NamespaceArray של השרת"]
A --> I["Index (בדוגמה הזו 2)"]
I --> N["NodeId ns=2;s=Line1.Temperature"]
K["Identifier Line1.Temperature"] --> N
איור 4: מוצאים את ה-namespace index לפי ה-URI, ומשלבים אותו עם ה-identifier כדי לקבל NodeId.
קריאה וכתיבה מחזירות status ו-timestamps יחד עם הערך
קוראים וכותבים ערכים של משתנים עם השירותים Read ו-Write8. קריאה לא מחזירה רק את הערך. היא מחזירה מבנה בשם DataValue, שכולל את הערך, status code, source timestamp ו-server timestamp9.
| פריט | משמעות |
|---|---|
| ערך | הערך של המשתנה. כשה-status code מציין שגיאה, מתעלמים מהערך והשרת מגדיר אותו כ-null |
| Status code (StatusCode) | האם השרת הצליח לקבל ולספק את הערך |
| SourceTimestamp | השעה שמקור הנתונים הצמיד לערך |
| ServerTimestamp | השעה שבה השרת קיבל את הערך, או שבה ידע שהוא נכון |
שני הביטים העליונים של ה-status code מציינים Good (הצלחה), Uncertain (הצלחה חלקית, ייתכן שלא שמיש לחלק מהשימושים) או Bad (כישלון)10. ה-Good [0x00000000] בפלט הוא Good.
כשקראתי את Line1.Pressure, שלא קיים, חזר BadNodeIdUnknown [0x80340000] במקום ערך. ReadValueAsync של הספרייה הזו זורק exception כשה-status הוא Bad4. אם מערכת ששומרת ערכים שומרת גם את ה-status code לצד כל ערך, אפשר אחר כך להבחין בערכים שנשמרו כשה-status היה Bad.
flowchart TB
accTitle: מה יש ב-DataValue
accDescr: תרשים שמראה שה-DataValue שחוזר מקריאה או מ-notification כולל ארבעה דברים, הערך, ה-status code, ה-source timestamp וה-server timestamp, ושמתעלמים מהערך כשה-status code הוא Bad.
D["DataValue"] --> V["ערך"]
D --> S["Status code"]
D --> TS["SourceTimestamp (השעה במקור)"]
D --> TV["ServerTimestamp (השעה בשרת)"]
S -->|"אם Bad"| X["מתעלמים מהערך"]
איור 5: תוצאת קריאה כוללת לא רק את הערך, אלא גם status code ושני timestamps.
בכתיבה כתבתי 32.5 ל-Setpoint, וה-status code שחזר היה Good. השרת קובע לאילו משתנים אפשר לכתוב. בשרת המדומה הזה רק Setpoint פתוח לכתיבה. השאלה אם לאפשר לשנות ערכי יעד של ציוד מבחוץ שייכת לתכנון הבטיחות של הציוד, ולכן המאמר לא עוסק בה.
לקבל הודעות על שינויים: subscriptions ו-MonitoredItems
כדי לעקוב אחרי שינויים בערכים, ה-client יכול לחזור על Read במרווח קבוע. מעבר לזה, ב-OPC UA יש מנגנון שמשאיר לשרת את ניטור הערכים ומבקש ממנו להודיע על שינויים. המנגנון הזה הוא subscriptions ו-MonitoredItems.
MonitoredItem הוא פריט אחד שמנוטר, ויש לו sampling interval. ה-sampling interval הוא הקצב המהיר ביותר שבו השרת דוגם ערכים מהמקור, והמפרט מתאר אותו כמחזור “best effort”11. כשדגימה עומדת בתנאים של ה-filter, נוצרת notification. אם לא מגדירים filter, נשלחת notification כשהערך או ה-status משתנים12.
ה-notifications נכנסות קודם לתור של ה-MonitoredItem. מחליטים מראש אם לזרוק את הישנה או את החדשה כשהתור מתמלא, ואם גודל התור הוא 2 או יותר, ביט ה-Overflow, שמציין שמשהו נזרק, נדלק ב-status code13.
Subscription מקבץ כמה MonitoredItems, ובכל publishing interval שולח ל-client את ה-notifications שעוד לא נשלחו. כשעוברים מחזורים בלי notifications לשלוח, הוא שולח keep-alive כדי להראות שה-subscription עדיין חי14.
flowchart TB
accTitle: איך subscriptions ו-MonitoredItems עובדים
accDescr: תרשים שמראה ש-MonitoredItem דוגם את הערך מהמקור לפי ה-sampling interval ומכניס לתור את ה-notifications שעברו את ה-filter, וש-subscription שולח אותן יחד ל-client לפי ה-publishing interval, ושולח keep-alive כשאין notifications.
S["המקור (הערך של הציוד)"] --> M["MonitoredItem"]
M -->|"Sampling interval"| F["Filter (ברירת מחדל: שינוי בערך או ב-status)"]
F --> Q["תור (Overflow כשהוא מתמלא)"]
Q --> SUB["Subscription"]
SUB -->|"Publishing interval"| C["Client"]
SUB -.->|"אם אין notifications"| K["Keep-alive"]
איור 6: ה-MonitoredItem דוגם ומכניס לתור, וה-subscription שולח את ה-notifications יחד ל-client.
הקוד שלמעלה נרשם ל-Temperature ול-PartCount עם sampling interval של 500 מילישניות וגודל תור 515. בפלט, הטמפרטורה מגיעה בערך כל 0.5 שניות ומספר היחידות בערך כל 2 שניות. שתי ה-notifications הראשונות היו הערכים שהיו לכל משתנה כשה-subscription התחיל. ה-13:40:50.801 של מספר היחידות הוא השעה שבה הערך השתנה, לפני שה-subscription התחיל. מכיוון שה-notifications מציגות את ה-source timestamp, מופיעה השעה שבה הערך השתנה, ולא השעה שבה ה-notification התקבלה.
מתי לחזור על קריאות ומתי להשתמש ב-subscription
כשחוזרים על קריאות, ה-client קובע את המרווח, והמנגנון פשוט. מצד שני, בקשה ותשובה עוברות כל פעם גם כשהערך לא השתנה, ושינויים קצרים מהמרווח מתפספסים. ב-subscription השרת דוגם ושולח רק שינויים, כך שהתעבורה כמעט לא גדלה גם לערכים שמשתנים לעיתים רחוקות. אבל גם ב-subscription, ייתכן ששינויים קצרים מה-sampling interval לא ידווחו. המפרט מאפשר לדגום מהר יותר ממה שביקשו, אבל לא מחייב את זה11.
flowchart TB
accTitle: ההבדל בין קריאות חוזרות ל-subscription
accDescr: תרשים שמראה שבקריאות חוזרות ה-client קובע את המרווח ושולח בקשה כל פעם, שב-subscription השרת דוגם לפי ה-sampling interval ושולח רק שינויים, ושבשתי הדרכים ייתכן ששינויים קצרים מהמרווח יתפספסו.
A["מעקב אחרי שינויים בערכים"] --> R["לחזור על קריאות"]
A --> S["להירשם ל-subscription"]
R --> R1["ה-client קובע מרווח ומבקש כל פעם"]
S --> S1["השרת דוגם ושולח רק שינויים"]
R1 --> X["שינויים קצרים מהמרווח עלולים להתפספס"]
S1 --> X
איור 7: בשתי הדרכים, שינויים קצרים מהמרווח עלולים להתפספס.
אישור חיבורים באמצעות certificates
בחיבור OPC UA בוחרים את רמת ההגנה על ההודעות עם MessageSecurityMode. הערכים הם None, בלי הגנה; Sign, חתימה בלבד; ו-SignAndEncrypt, חתימה והצפנה16. את שילוב האלגוריתמים לחתימה ולהצפנה קובעת ה-security policy, ובמאמר הזה השתמשתי ב-Basic256Sha256.
לחתימה ולהצפנה משתמשים ב-certificate לכל אפליקציה. ה-client והשרת בודקים זה את ה-certificate של זה, ודוחים חיבור מצד שהם לא סומכים עליו. שרת שנבנה עם הספרייה של OPC Foundation ל-.NET לא מציע כברירת מחדל את ה-policy בשם None, בלי הצפנה4.
החיבור הראשון נעצר עם BadCertificateUntrusted
בהרצה הראשונה ה-client לא סמך על ה-certificate של השרת, ולכן החיבור נדחה. זה חלק מהפלט.
fail: Opc.Ua.CertificateValidationCore[91]
Certificate [Subject=CN=DemoPlcServer, CN=urn:komurasoft:demo-plc, Thumbprint=4E236DDA629FE2AB32D185BF681609FD6C525363, NotBefore=2026-11-01, NotAfter=2027-11-01, KeyAlgorithm=1.2.840.113549.1.1.1] validation failed with suppressible errors but was rejected. Reason=Id: BadCertificateUntrusted
...
OPC UA エラー BadCertificateUntrusted [0x801A0000]: [801A0000] (BadCertificateUntrusted) 'Could not verify security on OpenSecureChannel response.'
הספרייה שומרת certificates שנדחו ב-store בשם rejected. כשמנהל מעביר certificate משם ל-store בשם trusted, הספרייה מתחילה לסמוך על אותו צד17. הפעם העברתי את ה-certificate של השרת מ-pki/rejected/certs ל-pki/trusted/certs, הרצתי שוב, והחיבור הצליח. לפני שמעבירים, מאמתים מול מנהל השרת את ה-thumbprint, שמופיע גם בשם הקובץ4.
flowchart TB
accTitle: השלבים עד שסומכים על certificate
accDescr: תרשים שמראה שבחיבור הראשון ה-client דוחה את ה-certificate של השרת ושומר אותו ב-store בשם rejected, ושאחרי אימות ה-thumbprint והעברה ל-store בשם trusted, החיבור הבא סומך עליו ומצליח.
A["החיבור הראשון"] --> B["BadCertificateUntrusted"]
B --> C["ה-certificate נשמר ב-store בשם rejected"]
C --> D["מאמתים את ה-thumbprint מול מנהל השרת"]
D --> E["מעבירים ל-store בשם trusted"]
E --> F["החיבור הבא סומך עליו ומצליח"]
איור 8: אחרי שבודקים certificate שנדחה ומעבירים אותו ל-trusted, ה-client סומך על אותו צד.
יש גם הגדרה בשם AutoAcceptUntrustedCertificates שמקבלת אוטומטית certificates שלא סומכים עליהם. ברירת המחדל היא false, והתיעוד של הספרייה אומר שהיא שימושית בסביבת פיתוח אבל לא צריך להשתמש בה ב-production17.
ה-NotAfter=2027-11-01 ב-log הוא תאריך התפוגה של ה-certificate של השרת המדומה. זה certificate בחתימה עצמית (self-signed) שנוצר עם פונקציה של asyncua, ותוקפו פג שנה אחרי היום שבו נוצר. גם ל-certificates של ציוד יש תוקף, ולכן קובעים כחלק מהתפעול איך מחליפים אותם לפני שתוקפם פג ואיך גורמים לצד השני לסמוך על החדשים. הטיפול ב-Certificate Store של Windows מוסבר ב-“Certificate Store ב-Windows”.
אימות המשתמש
ה-certificates מאמתים את האפליקציות זו מול זו. בנפרד מזה, כשפותחים session מעבירים token שמזהה את המשתמש. המפרט מגדיר ארבעה סוגים: anonymous, שם משתמש וסיסמה, certificate מסוג X.509, ו-token שהונפק מבחוץ18. ה-client במאמר התחבר כ-anonymous. אילו tokens השרת מקבל מופיע במידע על ה-endpoints של השרת19.
איפה איסוף נתונים בשטח נתקע
מכאן אני מפרט בעיות שנוטות לקרות במערכות שאוספות נתוני ציוד, על סמך הניסוי הזה והמפרט.
ערכים מהזמן שבו השרת היה כבוי לא מגיעים
באמצע ה-subscription כיביתי את השרת והפעלתי אותו מחדש אחרי כ-15 שניות. ה-ManagedSession של הספרייה מתחבר מחדש אוטומטית כשהחיבור נופל20. כל זמן שהשרת היה כבוי, ה-client ניסה שוב ושוב להתחבר מחדש.
warn: Opc.Ua.ChannelManager[6]
Reconnect failed. Endpoint=opc.tcp://localhost:4840/demo/, Attempt=3, Outcome=transient-failure, Status=BadNotConnected, ErrorMessage=Could not connect to the remote endpoint.
כשהשרת חזר, גם ה-notifications חזרו. זה חלק מהפלט של ה-subscription.
13:41:13.364 Line1.Temperature 25.61 Good [0x00000000]
13:41:13.865 Line1.Temperature 25.12 Good [0x00000000]
13:41:34.285 Line1.Temperature 26.55 Good [0x00000000]
13:41:33.784 Line1.PartCount 2 Good [0x00000000]
13:41:34.786 Line1.Temperature 26.16 Good [0x00000000]
אף ערך מ-13:41:13 עד 13:41:34 לא הגיע. מספר היחידות חזר מ-78 ל-2. זה קרה כי הפעלה מחדש של השרת המדומה גרמה לו לספור שוב מ-0.
בדוגמה הזו השרת עצמו היה כבוי, ולכן הערכים מהתקופה הזו לא נשמרו בשום מקום. מנגנון ה-reconnect מחזיר את החיבור, אבל לא משלים אחר כך את הערכים מהזמן שבו השרת היה כבוי.
המצב שונה כשרק התקשורת נופלת והשרת ממשיך לרוץ. במפרט יש שירות Republish, שמבקש מהשרת לשלוח שוב, מתור השידור החוזר שלו, הודעות notification שלא הגיעו21. גם בתור של ה-MonitoredItem מצטברות notifications, אבל מה שחורג מגודל התור נזרק13. בניסוי הזה לא בדקתי את המקרה הזה. בשני המקרים, שומרים תיעוד של זמני הניתוק וה-reconnect כדי לדעת באיזו תקופה לא הגיעו ערכים. Reconnect ותכנון log לציוד שמתקשר ב-serial מוסברים גם ב-“Pitfalls באפליקציות serial communication”.
flowchart TB
accTitle: מה לתעד כשהשרת נעצר
accDescr: תרשים שמראה שכשהשרת נעצר באמצע subscription, ה-client מנסה שוב ושוב להתחבר מחדש, וכשהשרת חוזר גם ה-notifications חוזרות, אבל הערכים מהזמן שבו היה כבוי לא מגיעים, ולכן מתעדים את זמני הניתוק וה-reconnect.
A["באמצע subscription"] --> B["השרת נעצר"]
B --> C["ה-client מנסה שוב ושוב reconnect"]
C --> D["השרת חוזר וה-notifications חוזרות"]
B -.-> E["ערכים מהזמן שבו היה כבוי לא מגיעים"]
E -.-> F["מתעדים את זמני הניתוק וה-reconnect"]
איור 9: ה-reconnect מחזיר את החיבור, אבל הערכים מהזמן שבו השרת היה כבוי לא מגיעים.
איזה timestamp לשמור
לפי המפרט, SourceTimestamp הוא השעה שמקור הנתונים הצמיד לערך, והיא אמורה לציין מתי הערך השתנה. ServerTimestamp הוא השעה שבה השרת קיבל את הערך, או שבה ידע שהוא נכון9. השרת המדומה במאמר מייצר את הערכים בעצמו, ולכן שתי השעות היו זהות.
כדי לדעת מתי ערך השתנה בציוד, משתמשים ב-SourceTimestamp. אבל השעה הזו נקבעת לפי השעון של המקור, כך שאם השעון של הציוד או של השרת לא מדויק, גם התיעוד לא מדויק. אם מתכוונים להצליב את הנתונים שנאספו עם תיעוד אחר, שומרים לצידם גם את השעה במחשב שקיבל אותם, כדי שאפשר יהיה לבדוק סטיות אחר כך. סנכרון השעה בצד של Windows מוסבר ב-“Windows Time Synchronization (w32time) and Business Systems”.
לא להתייחס לערכים שה-status שלהם אינו Good כאל מספרים רגילים
כשה-status code הוא Bad, צריך להתעלם מהערך9. כשהוא Uncertain, ייתכן שהערך לא שמיש לחלק מהשימושים10. לפני שמכניסים ערכים לגרפים או לסיכומים, ממיינים אותם לפי ה-status code ומחליטים מה לעשות עם ערכים שאינם Good. גם ביט ה-Overflow של תור שמתמלא נדלק בתוך ה-status code13. איך להציג על המסך את מצב הציוד מוסבר ב-“Best Practices for Checking and Displaying External Device State”.
לפתוח את ה-port ב-firewall
ה-port של OPC UA שרשום ב-IANA הוא 4840/TCP3. עם זאת, לא כל השרתים משתמשים במספר הזה. הדוגמאות של OPC Foundation ל-.NET משתמשות, למשל, ב-port 625414. בודקים את מספר ה-port ב-URL של ה-endpoint של הציוד, ומאפשרים חיבורים ל-port הזה. אם השרת רץ על Windows, מאפשרים את זה ב-Windows Firewall של אותו מחשב, ואם יש ציוד רשת בדרך, בציוד הזה. איך יוצרים inbound rules מוסבר ב-“Windows Firewall ואפליקציות עסקיות”.
flowchart TB
accTitle: איפה איסוף נתונים נתקע
accDescr: תרשים שמראה את המקומות שבהם איסוף נתוני ציוד נתקע, מחולקים לתוכן התיעוד (ערכים מהזמן שבו השרת היה כבוי לא מגיעים, איזה timestamp לשמור, טיפול בערכים שאינם Good) ולהכנת החיבור (ports ב-firewall, תפוגת certificates).
A["איפה איסוף נתונים נתקע"] --> R["תוכן התיעוד"]
A --> N["הכנת החיבור"]
R --> B["ערכים מזמן העצירה לא מגיעים"]
B ~~~ C["איזה timestamp"]
C ~~~ D["טיפול בערכים שאינם Good"]
N --> E["Ports ב-firewall"]
E ~~~ F["תפוגת certificates"]
איור 10: גם אחרי שהחיבור עובד, בודקים חוסרים בתיעוד, שעות, status, ports ו-certificates.
מתי להשתמש ב-OPC UA, ב-Modbus/TCP או בתקשורת של היצרן
לתקשורת עם PLC וציוד יש, מלבד OPC UA, גם את Modbus/TCP ואת התקשורת שיצרני ה-PLC מספקים.
המפרט של Modbus מטפל בנתונים בארבע טבלאות: Discrete Inputs, ביטים בודדים לקריאה בלבד; Coils, ביטים בודדים לקריאה ולכתיבה; Input Registers, מילים של 16 ביט לקריאה בלבד; ו-Holding Registers, מילים של 16 ביט לקריאה ולכתיבה22. ב-IANA רשום 502/TCP כפרוטוקול האפליקציה של Modbus (mbap)3.
בטבלאות האלה יש ביטים ומילים של 16 ביט, ולכן איזה ערך נמצא באיזו כתובת ובאיזה פורמט צריך לבדוק בתיעוד של הציוד ולכתוב בצד ה-client. ב-OPC UA, כמו שראינו, השרת חושף משתנים עם שמות ב-address space, וה-client יכול לגלות אותם עם Browse. לערכים מצורפים status code ו-timestamps, ולחיבורים יש אישור באמצעות certificates.
במה להשתמש ייקבע לרוב לפי מה שהציוד מספק. אם לציוד יש OPC UA server, משתמשים בו; אם יש רק Modbus/TCP, בונים טבלת מיפוי של כתובות וקוראים לפיה. אם יש רק התקשורת או ה-SDK של היצרן, הולכים לפי התיעוד שלהם. איך לבחור שיטת תקשורת עם התקני USB מוסבר ב-“How to Work with USB Devices from a Windows App”.
flowchart TB
accTitle: איך לבחור את התקשורת עם הציוד
accDescr: תרשים שמראה שאם לציוד יש OPC UA server עושים browse למשתנים ואוספים אותם עם OPC UA, אם יש רק Modbus/TCP בודקים כתובות ופורמטים בתיעוד וקוראים registers, ואם יש רק התקשורת של היצרן הולכים לפי התיעוד שלו.
A["התקשורת שהציוד מספק"] --> B{"מה יש?"}
B -->|"OPC UA server"| C["עושים browse למשתנים ואוספים"]
B -->|"רק Modbus/TCP"| D["בודקים כתובות ופורמטים בתיעוד"]
B -->|"רק תקשורת של היצרן"| E["הולכים לפי התיעוד וה-SDK של היצרן"]
D --> F["קוראים registers וממירים לערכים"]
איור 11: במה לאסוף נקבע לרוב לפי מה שהציוד מספק.
מאמרים קשורים
- Dropped Frames with GigE Vision and USB3 Vision Cameras — Locating the Cause and the Windows Settings to Review
- Pitfalls באפליקציות serial communication — reconnect ותכנון log
- Best Practices for Checking and Displaying External Device State
- How to Work with USB Devices from a Windows App
- Windows Firewall ואפליקציות עסקיות
- Certificate Store ב-Windows
- Windows Time Synchronization (w32time) and Business Systems
קישורי עיון
-
OPC Foundation, Classic. על כך ש-OPC Classic מבוסס על COM/DCOM, על המפרטים DA, A&E ו-HDA, על פרסום OPC UA ב-2008 ועל הסיבות לבנייה מחדש. ↩ ↩2
-
IEC, IEC TR 62541-1:2020 OPC Unified Architecture - Part 1: Overview and concepts. החלק הראשון בסדרה IEC 62541, שעוסק במושגים ובסקירה של OPC UA (הוחלף מאז במהדורה מאוחרת יותר). ↩
-
IANA, Service Name and Transport Protocol Port Number Registry. הרישום של
opcua-tcpעל 4840/TCP ושלmbapעל 502/TCP. ↩ ↩2 ↩3 -
OPC Foundation, UA-.NETStandard 2.0.1: Getting started. איך כותבים client בגרסאות 2.0, על כך ש-
ReadValueAsyncזורק exception כשהתוצאה Bad, על ה-stores בשם rejected ו-trusted, על כך שהשרתים לא מציעיםNoneכברירת מחדל, ועל מספרי ה-ports בדוגמאות. ↩ ↩2 ↩3 ↩4 ↩5 -
FreeOpcUa, asyncua (PyPI). ספריית OPC UA ל-Python ששימשה לשרת הציוד המדומה. ↩
-
OPC Foundation, OPC 10000-4 v1.05, 5.9.2 Browse. השירות שעוקב אחרי ה-references של node. ↩
-
OPC Foundation, OPC 10000-3 v1.05, 8.2.2 NamespaceIndex. על כך שבתוך השירותים ה-URI של ה-namespace מיוצג כמספר, ושהמספר הוא המיקום ב-NamespaceArray. ↩
-
OPC Foundation, OPC 10000-4 v1.05, 5.11 Attribute Service Set. השירותים Read ו-Write. ↩
-
OPC Foundation, OPC 10000-4 v1.05, 7.11 DataValue. ההגדרות של הערך, ה-status code, SourceTimestamp ו-ServerTimestamp. ↩ ↩2 ↩3
-
OPC Foundation, OPC 10000-4 v1.05, 7.38.1 StatusCode General. Good, Uncertain ו-Bad, כפי ששני הביטים העליונים מציינים. ↩ ↩2
-
OPC Foundation, OPC 10000-4 v1.05, 5.13.1.2 Sampling interval. על כך שה-sampling interval הוא מחזור best effort. ↩ ↩2
-
OPC Foundation, OPC 10000-4 v1.05, 7.22.2 DataChangeFilter. על כך שבלי filter נשלחת notification כשהערך או ה-status משתנים. ↩
-
OPC Foundation, OPC 10000-4 v1.05, 5.13.1.5 Queue parameters. גודל התור, מדיניות הזריקה וביט ה-Overflow. ↩ ↩2 ↩3
-
OPC Foundation, OPC 10000-4 v1.05, 5.14.1.1 Subscription model. ה-publishing interval וה-keep-alive. ↩
-
OPC Foundation, UA-.NETStandard 2.0.1: Subscriptions and Monitored Items Service Set.
SubscribeDataChangesAsync, וה-sampling interval וגודל התור ב-MonitoredItemOptions. ↩ -
OPC Foundation, OPC 10000-4 v1.05, 7.20 MessageSecurityMode. המשמעות של None, Sign ו-SignAndEncrypt. ↩
-
OPC Foundation, UA-.NETStandard 2.0.1: Certificates. הנוהג להעביר certificates מה-store בשם rejected ל-store שסומכים עליו, ועל כך שלא משתמשים ב-
AutoAcceptUntrustedCertificatesב-production. ↩ ↩2 -
OPC Foundation, OPC 10000-4 v1.05, 7.40 UserIdentityToken parameters. ארבעת הסוגים: anonymous, שם משתמש, X.509 ו-token שהונפק. ↩
-
OPC Foundation, OPC 10000-4 v1.05, 7.14 EndpointDescription. רשימת ה-user tokens ש-endpoint מקבל (userIdentityTokens). ↩
-
OPC Foundation, UA-.NETStandard 2.0.1: Sessions, Reconnection, and Subscription Engines. על כך שב-
ManagedSessionיש מנגנון reconnect מובנה. ↩ -
OPC Foundation, OPC 10000-4 v1.05, 5.14.6.1 Republish. השירות שמבקש לשלוח שוב הודעות notification מתור השידור החוזר של subscription. ↩
-
Modbus-IDA, MODBUS Application Protocol Specification V1.1b (עותק שנמצא על שרת של אוניברסיטת שיקגו). מודל הנתונים בסעיף 4.3 (Discrete Input, Coil, Input Register, Holding Register). ↩
מאמרים קשורים
מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.
למה arguments נשברים — כללי command-line arguments ב-Windows
Windows מעביר ל-CreateProcess מחרוזת אחת שהמקבל מפצל. מכסה את כללי CommandLineToArgvW, CRT ו-.NET, ArgumentList, ובניה ב-C++.
שיטות עבודה מומלצות ל-multithreading בפועל: מהדורת .NET — מה להחליט לפני שמוסיפים threads
כללי תכנון שמונעים מקוד multithreaded ב-.NET/C# לקרוס או להיתקע מדי פעם: להשתמש ב-Task במקום ליצור threads בעצמכם, לצמצם shared mutable s...
WMI/CIM מ-C# ומ-PowerShell — מדריך מעשי למידע חומרה, ניטור process ושאילתות remote
WMI/CIM הוא הדרך הסטנדרטית לשלוף serial number של מחשב, לנטר דיסק פנוי ולזהות process שהתחיל. המאמר מכסה CIM cmdlets כמו Get-CimInstance,...
מעמקי ה-I/O ב-Windows (פרק 4) — Cache Manager: מתי WriteFile באמת מגיע לדיסק
פרק 4 בסדרה שמסבירה בתרשימים את Cache Manager של Windows. המאמר עובר על מטמון שממומש כמיפוי קובץ, read-ahead ו-lazy writer, הבחירה בין Fl...
מעמקי ה-I/O של Windows (חלק 3) — I/O completion ports (IOCP) ו-thread pool של .NET: המרתף שמתחת ל-async/await
חלק 3 בסדרה עם איורים על I/O completion ports (IOCP): איחוד ה-queue עם בקרת מספר ה-threads, ערך ה-concurrency, שחרור ה-threads ב-LIFO והה...
נושאים קשורים
אלה דפי נושא הקרובים לנושא הזה. מהמאמר אפשר להמשיך לשירותים קשורים ולמאמרים אחרים.
נושאים טכניים ב-Windows
שער שמרכז נושאים טכניים בפיתוח Windows, בחקירת תקלות ובניצול נכסים קיימים.
שירותים הקשורים לנושא הזה
המאמר קשור ישירות לשירותים הבאים.
פיתוח יישומי Windows
בניית אפליקציית Windows שאוספת נתונים מציוד, כולל הגדרת ה-subscriptions, ה-reconnect ותיעוד ה-timestamps וה-status, אפשר לטפל בה כפיתוח אפליקציות Windows.
ייעוץ טכני וסקירת תכנון
אם צריך להחליט אם לאסוף עם OPC UA או להשתמש ב-Modbus/TCP או בפרוטוקול של היצרן, ואיך לנהל את ה-certificates, אפשר לטפל בזה כייעוץ טכני וסקירת תכנון.
שאלות נפוצות
שאלות נפוצות בהתייעצויות בנושא המאמר.
- מה ההבדל בין OPC UA ל-OPC Classic (OPC DA)?
- OPC Classic הוא אוסף מפרטים שמעבירים נתונים דרך COM/DCOM של Windows. OPC UA, שפורסם ב-2008, לא תלוי במערכת הפעלה מסוימת ומאחד את הפונקציות של Classic. Clients של OPC UA רצים גם מחוץ ל-Windows, ולא צריך להגדיר DCOM.
- כשאוספים ערכים עם OPC UA, מספיק לקרוא אותם במרווח קבוע?
- אם יש מעט ערכים והמרווח ארוך, קריאה מספיקה. כדי לעקוב אחרי שינויים בערכים אפשר להשתמש ב-subscription: משאירים לשרת את ה-sampling, והוא מודיע על שינויים. בשתי הדרכים, שומרים את ה-status code ואת ה-timestamps שחוזרים עם כל ערך.
- למה מקבלים BadCertificateUntrusted כשמתחברים?
- כי ה-client עדיין לא סומך על ה-certificate של השרת. הספרייה של OPC Foundation ל-.NET שומרת certificates שנדחו ב-store בשם rejected. מאמתים את ה-thumbprint של ה-certificate מול מנהל השרת, ואז מעבירים אותו ל-store בשם trusted. הגדרה שמקבלת certificates אוטומטית מתאימה רק לסביבת פיתוח.
- באיזה port משתמש OPC UA?
- ב-IANA רשום 4840/TCP בשם opcua-tcp. עם זאת, שרתים אמיתיים משתמשים לפעמים ב-port אחר, אז בודקים בהגדרות הציוד. אם השרת רץ על Windows, צריך לאפשר תעבורה ל-port הזה ב-Windows Firewall, ואם יש ציוד רשת בדרך, גם בציוד הזה.