Skip to content

feat(OpcDa): add OpcDa lib#514

Merged
ArgoZhang merged 25 commits intomasterfrom
feat-opc-ua
Aug 5, 2025
Merged

feat(OpcDa): add OpcDa lib#514
ArgoZhang merged 25 commits intomasterfrom
feat-opc-ua

Conversation

@ArgoZhang
Copy link
Copy Markdown
Member

@ArgoZhang ArgoZhang commented Aug 5, 2025

Link issues

fixes #513

Summary By Copilot

Regression?

  • Yes
  • No

Risk

  • High
  • Medium
  • Low

Verification

  • Manual (required)
  • Automated

Packaging changes reviewed?

  • Yes
  • No
  • N/A

☑️ Self Check before Merge

⚠️ Please check all items below before review. ⚠️

  • Doc is updated/provided or not needed
  • Demo is updated/provided or not needed
  • Merge the latest code from the main branch

Summary by Sourcery

Introduce a new OPC DA extension for BootstrapBlazor that implements core server interaction APIs, data models, and DI integration, accompanied by unit tests for both OPC DA and OPC UA scenarios

New Features:

  • Add OPC DA library with IOpcServer implementation for connecting, reading, writing, and subscribing to OPC DA servers
  • Define OPC DA data models (OpcReadItem, OpcWriteItem, Quality enum) and subscription abstraction (ISubscription, OpcSubscription)
  • Provide dependency injection extension (AddOpcServer) to register the OPC DA service
  • Implement utility components including OpcItemEqualityComparer and extension methods for quality conversion and subscription mapping

Build:

  • Include project files, solution updates, and build targets for the new BootstrapBlazor.OpcDa extension

Tests:

  • Add unit tests for OPC DA operations covering connect, read, write, and subscription workflows
  • Add unit tests for basic OPC UA client connectivity, server browsing, and value reading

@bb-auto bb-auto Bot added the enhancement New feature or request label Aug 5, 2025
@bb-auto bb-auto Bot added this to the v9.2.0 milestone Aug 5, 2025
@sourcery-ai
Copy link
Copy Markdown

sourcery-ai Bot commented Aug 5, 2025

Reviewer's Guide

This PR adds a new OPC DA extension library to BootstrapBlazor, defining core service and subscription interfaces, concrete implementations for OPC DA server operations (connect, read, write, subscriptions), data models and utilities, DI registration, and comprehensive unit tests.

Class diagram for new OPC DA extension library

classDiagram
    class IOpcServer {
        +bool IsConnected
        +string? ServerName
        +bool Connect(string serverName)
        +void Disconnect()
        +void CancelSubscription(ISubscription subscription)
        +ISubscription CreateSubscription(string name, int updateRate = 1000, bool active = true)
        +HashSet<OpcReadItem> Read(params HashSet<string> items)
        +HashSet<OpcWriteItem> Write(params HashSet<OpcWriteItem> items)
    }
    class OpcServer {
        -Opc.Da.Server? _server
        -ConcurrentDictionary<string, HashSet<OpcReadItem>> _valuesCache
        +string? ServerName
        +bool IsConnected
        +bool Connect(string serverName)
        +void Disconnect()
        +ISubscription CreateSubscription(string name, int updateRate = 1000, bool active = true)
        +void CancelSubscription(ISubscription subscription)
        +HashSet<OpcReadItem> Read(params HashSet<string> items)
        +HashSet<OpcWriteItem> Write(params HashSet<OpcWriteItem> items)
        +void Dispose()
    }
    IOpcServer <|.. OpcServer

    class ISubscription {
        +bool KeepLastValue
        +Action<List<OpcReadItem>>? DataChanged
        +Opc.Da.ISubscription GetSubscription()
        +void AddItems(IEnumerable<string> items)
    }
    class OpcSubscription {
        +Action<List<OpcReadItem>>? DataChanged
        +bool KeepLastValue
        +Opc.Da.ISubscription GetSubscription()
        +void AddItems(IEnumerable<string> items)
    }
    ISubscription <|.. OpcSubscription

    class IOpcItem {
        +string Name
    }
    class OpcReadItem {
        +string Name
        +Quality Quality
        +DateTime Timestamp
        +object? Value
        +object? LastValue
    }
    class OpcWriteItem {
        +string Name
        +object? Value
        +bool Result
    }
    IOpcItem <|.. OpcReadItem
    IOpcItem <|.. OpcWriteItem

    class OpcItemEqualityComparer~TItem~ {
        +static OpcItemEqualityComparer<TItem> Default
        +bool Equals(TItem? x, TItem? y)
        +int GetHashCode(TItem item)
    }

    class Quality {
        <<enum>>
        Bad
        Good
    }
Loading

Class diagram for service registration extension

classDiagram
    class ServiceCollectionExtensions {
        +IServiceCollection AddOpcServer(this IServiceCollection services)
    }
Loading

File-Level Changes

Change Details Files
Introduce core OPC DA service interface and implementation
  • Define IOpcServer interface with connect/disconnect, read/write, subscription methods
  • Implement OpcServer using Opc.Da.Server, handling connect/disconnect, Read/Write mapping, Dispose
  • Register IOpcServer in DI via AddOpcServer extension with Windows platform attribute
src/extensions/BootstrapBlazor.OpcDa/IOpcServer.cs
src/extensions/BootstrapBlazor.OpcDa/OpcServer.cs
src/extensions/BootstrapBlazor.OpcDa/Extensions/ServiceCollectionExtensions.cs
Add subscription abstraction and adapter
  • Define ISubscription interface with DataChanged callback and item management
  • Implement OpcSubscription to wrap Opc.Da.ISubscription, map DataChanged events, support KeepLastValue
  • Provide extension ToOpcSubscription for converting raw subscriptions
src/extensions/BootstrapBlazor.OpcDa/ISubscription.cs
src/extensions/BootstrapBlazor.OpcDa/OpcSubscription.cs
src/extensions/BootstrapBlazor.OpcDa/Extensions/Extensions.cs
Define OPC item models and helpers
  • Create IOpcItem, record structs OpcReadItem and OpcWriteItem for data representation
  • Add Quality enum and extension method to map Opc.Da.Quality
  • Implement OpcItemEqualityComparer for item deduplication
src/extensions/BootstrapBlazor.OpcDa/IOpcItem.cs
src/extensions/BootstrapBlazor.OpcDa/OpcReadItem.cs
src/extensions/BootstrapBlazor.OpcDa/OpcWriteItem.cs
src/extensions/BootstrapBlazor.OpcDa/Quality.cs
src/extensions/BootstrapBlazor.OpcDa/OpcItemEqualityComparer.cs
Integrate projects and add unit tests
  • Update solution and NuGet.Config to include new OPC DA project and build targets
  • Add UnitTestOpcDa suite exercising connect, read/write, and subscription workflows
  • Include UnitTestOpcUa tests for existing UA client scenarios
BootstrapBlazor.Extensions.sln
NuGet.Config
test/UnitTestOpcDa/UnitTest1.cs
test/UnitTestOpcUa/UnitTest1.cs

Assessment against linked issues

Issue Objective Addressed Explanation
#513 Add an OpcDa library to the project, providing code to support OPC DA server connectivity, reading, writing, and subscription functionality.

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @ArgoZhang - I've reviewed your changes - here's some feedback:

  • Replace the Read and Write method signatures (using params HashSet<…>) with more conventional params string[] or IEnumerable overloads to simplify the API and caller usage.
  • Remove or implement the unused private _valuesCache field in OpcServer to avoid confusion or dead code.
  • Wrap OPC COM calls (e.g. _server.Connect, Read, Write) in try/catch blocks to provide clearer error feedback instead of letting COM exceptions bubble up.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- Replace the Read and Write method signatures (using params HashSet<…>) with more conventional params string[] or IEnumerable<T> overloads to simplify the API and caller usage.
- Remove or implement the unused private _valuesCache field in OpcServer to avoid confusion or dead code.
- Wrap OPC COM calls (e.g. _server.Connect, Read, Write) in try/catch blocks to provide clearer error feedback instead of letting COM exceptions bubble up.

## Individual Comments

### Comment 1
<location> `src/extensions/BootstrapBlazor.OpcDa/OpcServer.cs:104` </location>
<code_context>
+    /// </summary>
+    /// <param name="items"></param>
+    /// <returns></returns>
+    public HashSet<OpcReadItem> Read(params HashSet<string> items)
+    {
+        var server = GetOpcServer();
</code_context>

<issue_to_address>
Using params with HashSet<string> may be confusing and unnecessary.

'params' is generally used with arrays, not collections like HashSet. Consider changing the parameter to 'HashSet<string> items' or 'params string[] items' for clarity and consistency.
</issue_to_address>

<suggested_fix>
<<<<<<< SEARCH
    /// <summary>
    /// 读取指定 Item 值方法
    /// </summary>
    /// <param name="items"></param>
    /// <returns></returns>
+    public HashSet<OpcReadItem> Read(params HashSet<string> items)
+    {
+        var server = GetOpcServer();
=======
    /// <summary>
    /// 读取指定 Item 值方法
    /// </summary>
    /// <param name="items"></param>
    /// <returns></returns>
    public HashSet<OpcReadItem> Read(HashSet<string> items)
    {
        var server = GetOpcServer();
>>>>>>> REPLACE

</suggested_fix>

### Comment 2
<location> `src/extensions/BootstrapBlazor.OpcDa/OpcServer.cs:116` </location>
<code_context>
+    /// </summary>
+    /// <param name="items"></param>
+    /// <returns></returns>
+    public HashSet<OpcWriteItem> Write(params HashSet<OpcWriteItem> items)
+    {
+        var server = GetOpcServer();
</code_context>

<issue_to_address>
The use of 'params' with HashSet<OpcWriteItem> is unconventional.

Consider changing the parameter to 'params OpcWriteItem[] items' or simply 'HashSet<OpcWriteItem> items' for clarity and consistency.

Suggested implementation:

```csharp
    /// <param name="items">The OPC items to write.</param>
    /// <returns></returns>
    public HashSet<OpcWriteItem> Write(params OpcWriteItem[] items)
    {
        var server = GetOpcServer();

```

```csharp
        var results = server.Read([.. items.Select(i => new Item() { ItemName = i })]);
        return results.Select(i => new OpcReadItem(i.ItemName, i.Quality.ToQuality(), i.Timestamp, i.Value)).ToHashSet(OpcItemEqualityComparer<OpcReadItem>.Default);
    }

```

If the method is called elsewhere in the codebase, those call sites may need to be updated to pass individual `OpcWriteItem` arguments or an array, rather than a `HashSet<OpcWriteItem>`.
</issue_to_address>

### Comment 3
<location> `src/extensions/BootstrapBlazor.OpcDa/OpcSubscription.cs:33` </location>
<code_context>
+                    Quality = i.Quality == Opc.Da.Quality.Good ? Quality.Good : Quality.Bad,
+                    Timestamp = i.Timestamp
+                };
+                if (KeepLastValue)
+                {
+                    var v = _cache.Find(i => i.Name == item.Name);
+                    item.LastValue = v.Value;
+                }
</code_context>

<issue_to_address>
Potential NullReferenceException when accessing v.Value if v is not found.

Check if 'v' is not null before accessing 'v.Value' to prevent a NullReferenceException.
</issue_to_address>

### Comment 4
<location> `src/extensions/BootstrapBlazor.OpcDa/OpcItemEqualityComparer.cs:30` </location>
<code_context>
+    /// </summary>
+    /// <param name="item"></param>
+    /// <returns></returns>
+    public int GetHashCode([DisallowNull] TItem item) => item.Name.GetHashCode();
+}
</code_context>

<issue_to_address>
GetHashCode does not handle null Name property.

Add a null check for Name or enforce that Name cannot be null to prevent NullReferenceException.
</issue_to_address>

### Comment 5
<location> `test/UnitTestOpcDa/UnitTest1.cs:14` </location>
<code_context>
+[SupportedOSPlatform("windows")]
+public class UnitTest1
+{
+    [Fact]
+    public void Write_Ok()
+    {
+        var sc = new ServiceCollection();
</code_context>

<issue_to_address>
Missing negative and error case tests for OPC DA server connection and operations.

Please add tests for invalid server names, invalid item names, and operations when not connected to improve error handling coverage.
</issue_to_address>

### Comment 6
<location> `test/UnitTestOpcDa/UnitTest1.cs:30` </location>
<code_context>
+        Assert.Equal(2, values.Count);
+        Assert.All(values, v => Assert.Equal(Quality.Good, v.Quality));
+
+        var results = server.Write([
+            new OpcWriteItem()
+            {
+                Name = "Channel1.Device1.Tag2",
+                Value = 123
+            }
+        ]);
+        Assert.All(results, v => Assert.True(v.Result));
+
+        server.Disconnect();
</code_context>

<issue_to_address>
Test does not verify write failures or partial successes.

Please add a test case where a write operation is expected to fail (e.g., using an invalid tag name) and verify that the Result property is false for failed writes.
</issue_to_address>

<suggested_fix>
<<<<<<< SEARCH
        var results = server.Write([
            new OpcWriteItem()
            {
                Name = "Channel1.Device1.Tag2",
                Value = 123
            }
        ]);
        Assert.All(results, v => Assert.True(v.Result));

        server.Disconnect();
=======
        var results = server.Write([
            new OpcWriteItem()
            {
                Name = "Channel1.Device1.Tag2",
                Value = 123
            }
        ]);
        Assert.All(results, v => Assert.True(v.Result));

        // Test write failure with invalid tag name
        var failedResults = server.Write([
            new OpcWriteItem()
            {
                Name = "Invalid.Tag.Name",
                Value = 456
            }
        ]);
        Assert.Single(failedResults);
        Assert.False(failedResults[0].Result);

        // Test partial success: one valid, one invalid
        var mixedResults = server.Write([
            new OpcWriteItem()
            {
                Name = "Channel1.Device1.Tag2",
                Value = 789
            },
            new OpcWriteItem()
            {
                Name = "Invalid.Tag.Name",
                Value = 1011
            }
        ]);
        Assert.Equal(2, mixedResults.Count);
        Assert.True(mixedResults[0].Result);
        Assert.False(mixedResults[1].Result);

        server.Disconnect();
>>>>>>> REPLACE

</suggested_fix>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +99 to +106
/// <summary>
/// 读取指定 Item 值方法
/// </summary>
/// <param name="items"></param>
/// <returns></returns>
public HashSet<OpcReadItem> Read(params HashSet<string> items)
{
var server = GetOpcServer();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Using params with HashSet may be confusing and unnecessary.

'params' is generally used with arrays, not collections like HashSet. Consider changing the parameter to 'HashSet items' or 'params string[] items' for clarity and consistency.

Suggested change
/// <summary>
/// 读取指定 Item 值方法
/// </summary>
/// <param name="items"></param>
/// <returns></returns>
public HashSet<OpcReadItem> Read(params HashSet<string> items)
{
var server = GetOpcServer();
/// <summary>
/// 读取指定 Item 值方法
/// </summary>
/// <param name="items"></param>
/// <returns></returns>
public HashSet<OpcReadItem> Read(HashSet<string> items)
{
var server = GetOpcServer();

/// </summary>
/// <param name="items"></param>
/// <returns></returns>
public HashSet<OpcWriteItem> Write(params HashSet<OpcWriteItem> items)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: The use of 'params' with HashSet is unconventional.

Consider changing the parameter to 'params OpcWriteItem[] items' or simply 'HashSet items' for clarity and consistency.

Suggested implementation:

    /// <param name="items">The OPC items to write.</param>
    /// <returns></returns>
    public HashSet<OpcWriteItem> Write(params OpcWriteItem[] items)
    {
        var server = GetOpcServer();
        var results = server.Read([.. items.Select(i => new Item() { ItemName = i })]);
        return results.Select(i => new OpcReadItem(i.ItemName, i.Quality.ToQuality(), i.Timestamp, i.Value)).ToHashSet(OpcItemEqualityComparer<OpcReadItem>.Default);
    }

If the method is called elsewhere in the codebase, those call sites may need to be updated to pass individual OpcWriteItem arguments or an array, rather than a HashSet<OpcWriteItem>.

Comment on lines +33 to +35
if (KeepLastValue)
{
var v = _cache.Find(i => i.Name == item.Name);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Potential NullReferenceException when accessing v.Value if v is not found.

Check if 'v' is not null before accessing 'v.Value' to prevent a NullReferenceException.

/// </summary>
/// <param name="item"></param>
/// <returns></returns>
public int GetHashCode([DisallowNull] TItem item) => item.Name.GetHashCode();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: GetHashCode does not handle null Name property.

Add a null check for Name or enforce that Name cannot be null to prevent NullReferenceException.

Comment on lines +14 to +15
[Fact]
public void Write_Ok()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Missing negative and error case tests for OPC DA server connection and operations.

Please add tests for invalid server names, invalid item names, and operations when not connected to improve error handling coverage.

Comment on lines +30 to +39
var results = server.Write([
new OpcWriteItem()
{
Name = "Channel1.Device1.Tag2",
Value = 123
}
]);
Assert.All(results, v => Assert.True(v.Result));

server.Disconnect();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Test does not verify write failures or partial successes.

Please add a test case where a write operation is expected to fail (e.g., using an invalid tag name) and verify that the Result property is false for failed writes.

Suggested change
var results = server.Write([
new OpcWriteItem()
{
Name = "Channel1.Device1.Tag2",
Value = 123
}
]);
Assert.All(results, v => Assert.True(v.Result));
server.Disconnect();
var results = server.Write([
new OpcWriteItem()
{
Name = "Channel1.Device1.Tag2",
Value = 123
}
]);
Assert.All(results, v => Assert.True(v.Result));
// Test write failure with invalid tag name
var failedResults = server.Write([
new OpcWriteItem()
{
Name = "Invalid.Tag.Name",
Value = 456
}
]);
Assert.Single(failedResults);
Assert.False(failedResults[0].Result);
// Test partial success: one valid, one invalid
var mixedResults = server.Write([
new OpcWriteItem()
{
Name = "Channel1.Device1.Tag2",
Value = 789
},
new OpcWriteItem()
{
Name = "Invalid.Tag.Name",
Value = 1011
}
]);
Assert.Equal(2, mixedResults.Count);
Assert.True(mixedResults[0].Result);
Assert.False(mixedResults[1].Result);
server.Disconnect();

@ArgoZhang ArgoZhang merged commit a1ccad8 into master Aug 5, 2025
1 check passed
@ArgoZhang ArgoZhang deleted the feat-opc-ua branch August 5, 2025 07:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(OpcDa): add OpcDa lib

1 participant