diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..23fd735 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,20 @@ +# EditorConfig is awesome: http://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Baseline +[*] +charset = utf-8-bom +indent_style = space +indent_size = 4 +tab_width = 4 +trim_trailing_whitespace = true +max_line_length = 200 +insert_final_newline = true + +[*.{json,xml,csproj,config}] +indent_size = 2 + +[Directory.*.props] +indent_size = 2 diff --git a/.github/workflows/dotnet-core.yml b/.github/workflows/dotnet-core.yml deleted file mode 100644 index f11645f..0000000 --- a/.github/workflows/dotnet-core.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: .NET Core Build - -on: - push: - branches: - - master - pull_request: - branches: [ master ] - -jobs: - build: - - runs-on: windows-latest - - steps: - - uses: actions/checkout@v2 - - name: Setup .NET Core - uses: actions/setup-dotnet@v1 - with: - dotnet-version: 5.0.x - - name: Install NUnit.ConsoleRunner - run: nuget install NUnit.ConsoleRunner -Version 3.13.0 -DirectDownload -OutputDirectory . - - name: Install dependencies - run: dotnet restore - - name: Build - run: dotnet build --configuration Release --no-restore /p:version=1.1.${{ github.run_number }} - - name: Tests - run: ./NUnit.ConsoleRunner.3.13.0/tools/nunit3-console.exe "Sharp7.Rx.Tests\bin\Release\net461\Sharp7.Rx.Tests.dll" - - name: NugetPublish - run: dotnet nuget push Sharp7.Rx\bin\Release\Sharp7.Rx.1.1.${{ github.run_number }}.nupkg -s https://api.nuget.org/v3/index.json -k ${{ secrets.NUGET_DEPLOY_KEY }} - - name: Create Release - id: create_release - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: 1.1.${{ github.run_number }} - release_name: 1.1.${{ github.run_number }} - draft: false - prerelease: true \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3b7efd5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,38 @@ +name: Release + +on: + push: + branches: + - master + - prerelease + pull_request: + branches: [ master ] + + +jobs: + build: + + runs-on: windows-latest + env: + version: 2.0.${{ github.run_number }}${{ github.ref == 'refs/heads/master' && '' || '-prerelease' }} + + steps: + - uses: actions/checkout@v2 + - name: Setup .NET Core + uses: actions/setup-dotnet@v1 + with: + dotnet-version: 8.0.x + - name: Install NUnit.ConsoleRunner + run: nuget install NUnit.ConsoleRunner -Version 3.17.0 -DirectDownload -OutputDirectory . + - name: Install dependencies + run: dotnet restore + - name: Install dependencies + run: dotnet restore + - name: Build + run: dotnet build --configuration Release --no-restore /p:version=${{ env.version }} + - name: Tests + run: ./NUnit.ConsoleRunner.3.17.0/tools/nunit3-console.exe "Sharp7.Rx.Tests\bin\Release\net8.0\Sharp7.Rx.Tests.dll" + - name: NugetPublish + if: github.event_name == 'push' + run: dotnet nuget push Sharp7.Rx\bin\Release\Sharp7.Rx.${{ env.version }}.nupkg -s https://api.nuget.org/v3/index.json -k ${{ secrets.NUGET_DEPLOY_KEY }} + diff --git a/README.md b/README.md index a5f27b9..49b2256 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,9 @@ It combines the S7 communication library with the power of System.Reactive. - Threadsafe (Sharp7 is basically not threadsafe) ## Quick start + +Install the [Sharp7.Rx Nuget package](https://www.nuget.org/packages/Sharp7.Rx). + The example below shows you how to create and use the Sharp7Rx PLC. ```csharp @@ -24,19 +27,15 @@ using (var disposables = new CompositeDisposable()) var plc = new Sharp7Plc("10.30.3.10", 0, 2); disposables.Add(plc); - // initialize the plc - await plc.InitializeAsync(); - - //wait for the plc to get connected - await plc.ConnectionState - .FirstAsync(c => c == Sharp7.Rx.Enums.ConnectionState.Connected) - .ToTask(); + // initialize and connect to the plc + await plc.InitializeConnection(); await plc.SetValue("DB2.DBX0.4", true); // set a bit - var bit = await plc.GetValue("DB2.int4"); // get a bit - + var value = await plc.GetValue("DB2.Int4"); // get an 16 bit integer + Console.WriteLine(value) + // create a nofication for data change in the plc - var notification = plc.CreateNotification("DB1.DBX0.2", TransmissionMode.OnChange, TimeSpan.FromMilliseconds(100)) + var notification = plc.CreateNotification("DB1.DBX0.2", TransmissionMode.OnChange) .Where(b => b) //select rising edge .Do(_ => doStuff()) .Subscribe(); @@ -48,23 +47,59 @@ using (var disposables = new CompositeDisposable()) } ``` -the best way to test your PLC application is running your [SoftPLC](https://github.com/fbarresi/softplc) locally. +The best way to test your PLC application is running your [SoftPLC](https://github.com/fbarresi/softplc) locally. -## S7 Addressing rules +## Examples + +This library comes with integrated [LinqPad](https://www.linqpad.net/) examples - even for the free edition. Just download the [Sharp7.Rx Nuget package](https://www.nuget.org/packages/Sharp7.Rx) after pressing `Ctrl + Shift + P` and browse the "Samples". + +[Sharp7Monitor](https://github.com/Peter-B-/Sharp7.Monitor) is a console application for monitoring S7 variables over RFC1006, based on this library. + +## Addressing rules Sharp7Reactive uses a syntax for identifying addresses similar to official siemens syntax. -Every address has the form (case unsensible) `DB..`. -
i.e.: `DB42.DBX0.7` => (means) Datablock 42, bit (DBX), Start: 0, Position: 7 -
or
-`DB42.DBB4.25` => (means) Datablock 42, bytes (DBB), Start: 4, Length: 25. +Every address has the form (case unsensitive) `DB..`. -Following types are supported: -- `DBX` => Bit (bool) -- `DBB` => byte or byte[] -- `INT` -- `DINT` -- `DUL` => LINT -- `D` => REAL +| Example | Explaination | +| ------------------------------------ | ----------------------------------------------------------------- | +| `DB42.Int4` or
`DB42.DBD4` | Datablock 42, 16 bit integer from bytes 4 to 5 (zero based index) | +| `DB42.Bit0.7` or
`DB42.DBX0.7` | Datablock 42, bit from byte 0, position 7 | +| `DB42.Byte4.25` or
`DB42.DBB4.25` | Datablock 42, 25 bytes from byte 4 to 29 (zero based index) | + +Here is a table of supported data types: + +|.Net Type|Identifier |Description |Length or bit |Example |Example remark | +|---------|-----------------------------|----------------------------------------------|----------------------------------------|-------------------|--------------------------| +|bool |bit, dbx |Bit as boolean value |Bit index (0 .. 7) |`Db200.Bit2.2` |Reads bit 3 | +|byte |byte, dbb, b* |8 bit unsigned integer | |`Db200.Byte4` | | +|byte[] |byte, dbb, b* |Array of bytes |Array length in bytes |`Db200.Byte4.16` | | +|short |int, dbw, w* |16 bit signed integer | |`Db200.Int4` | | +|ushort |uint |16 bit unsigned integer | |`Db200.UInt4` | | +|int |dint, dbd |32 bit signed integer | |`Db200.DInt4` | | +|uint |udint |32 bit unsigned integer | |`Db200.UDInt4` | | +|long |lint |64 bit signed integer | |`Db200.LInt4` | | +|ulong |ulint, dul*, dulint*, dulong*|64 bit unsigned integer | |`Db200.ULInt4` | | +|float |real, d* |32 bit float | |`Db200.Real4` | | +|double |lreal |64 bit float | |`Db200.LReal4` | | +|string |string, s* |ASCII text string with string size |String length in bytes (1 .. 254) |`Db200.String4.16` |Uses 18 bytes = 16 + 2 | +|string |wstring |UTF-16 Big Endian text string with string size|String length in characters (1 .. 16382)|`Db200.WString4.16`|Uses 36 bytes = 16 * 2 + 4| +|string |byte[] |ASCII string as byte array |String length in bytes |`Db200.Byte4.16` |Uses 16 bytes | + +> Identifiers marked with * are kept for compatability reasons and might be removed in the future. + +## Performance considerations + +Frequent reads of variables using `GetValue` can cause performance pressure on the S7 PLC, resulting in an increase of cycle time. + +If you frequently read variables, like polling triggers, use `CreateNotification`. Internally all variable polling initialized with `CreateNotification` is pooled to a single (or some) multi-variable-reads. + +You can provide a cycle time (delay between consecutive multi variable reads) in the `Sharp7Plc` constructor: + +```csharp +public Sharp7Plc(string ipAddress, int rackNumber, int cpuMpiAddress, int port = 102, TimeSpan? multiVarRequestCycleTime = null) +``` + +The default value for `multiVarRequestCycleTime` is 100 ms, the minimal value is 5 ms. ## Would you like to contribute? diff --git a/Sharp7.Rx.Tests/ParsingS7VariableName.cs b/Sharp7.Rx.Tests/ParsingS7VariableName.cs deleted file mode 100644 index 0dc256a..0000000 --- a/Sharp7.Rx.Tests/ParsingS7VariableName.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Windows.Forms; -using TechTalk.SpecFlow; -using TechTalk.SpecFlow.Assist; - -namespace Sharp7.Rx.Tests -{ - [Binding] - public class ParsingS7VariableName - { - private S7VariableNameParser parser; - - [Given(@"I have an Parser")] - public void GivenIHaveAnParser() - { - parser = new S7VariableNameParser(); - } - - [Given(@"I have the following variables")] - public void GivenIHaveTheFollowingVariables(Table table) - { - var names = table.CreateSet(); - - ScenarioContext.Current.Set(names); - } - - [When(@"I parse the var name")] - public void WhenIParseTheVarName() - { - var names = ScenarioContext.Current.Get>(); - var addresses = names.Select(v => parser.Parse(v.VarName)).ToArray(); - - ScenarioContext.Current.Set(addresses); - } - - [Then(@"the result should be")] - public void ThenTheResultShouldBe(Table table) - { - var addresses = ScenarioContext.Current.Get(); - table.CompareToSet(addresses); - } - } - - class Vars - { - public string VarName { get; set; } - } -} diff --git a/Sharp7.Rx.Tests/ParsingS7VariableName.feature b/Sharp7.Rx.Tests/ParsingS7VariableName.feature deleted file mode 100644 index 82d3be1..0000000 --- a/Sharp7.Rx.Tests/ParsingS7VariableName.feature +++ /dev/null @@ -1,40 +0,0 @@ -Feature: ParsingS7VariableName - -@mytag -Scenario: Parsing variable name for bool - Given I have an Parser - And I have the following variables - | VarName | - | DB13.DBX3.1 | - | Db403.X5.2 | - | DB55DBX23.6 | - | DB1.S255 | - | DB1.S255.20 | - | DB5.String887.20 | - | DB506.B216 | - | DB506.DBB216.5 | - | DB506.D216 | - | DB506.DINT216 | - | DB506.INT216 | - | DB506.DBW216 | - | DB506.DUL216 | - | DB506.DULINT216 | - | DB506.DULONG216 | - When I parse the var name - Then the result should be - | Operand | DbNr | Start | Length | Bit | Type | - | Db | 13 | 3 | 1 | 1 | Bit | - | Db | 403 | 5 | 1 | 2 | Bit | - | Db | 55 | 23 | 1 | 6 | Bit | - | Db | 1 | 255 | 0 | 0 | String | - | Db | 1 | 255 | 20 | 0 | String | - | Db | 5 | 887 | 20 | 0 | String | - | Db | 506 | 216 | 1 | 0 | Byte | - | Db | 506 | 216 | 5 | 0 | Byte | - | Db | 506 | 216 | 4 | 0 | Double | - | Db | 506 | 216 | 4 | 0 | DInteger | - | Db | 506 | 216 | 2 | 0 | Integer | - | Db | 506 | 216 | 2 | 0 | Integer | - | Db | 506 | 216 | 8 | 0 | ULong | - | Db | 506 | 216 | 8 | 0 | ULong | - | Db | 506 | 216 | 8 | 0 | ULong | diff --git a/Sharp7.Rx.Tests/ParsingS7VariableName.feature.cs b/Sharp7.Rx.Tests/ParsingS7VariableName.feature.cs deleted file mode 100644 index 18959ea..0000000 --- a/Sharp7.Rx.Tests/ParsingS7VariableName.feature.cs +++ /dev/null @@ -1,242 +0,0 @@ -// ------------------------------------------------------------------------------ -// -// This code was generated by SpecFlow (http://www.specflow.org/). -// SpecFlow Version:2.4.0.0 -// SpecFlow Generator Version:2.4.0.0 -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -// ------------------------------------------------------------------------------ -#region Designer generated code -#pragma warning disable -namespace Sharp7.Rx.Tests -{ - using TechTalk.SpecFlow; - - - [System.CodeDom.Compiler.GeneratedCodeAttribute("TechTalk.SpecFlow", "2.4.0.0")] - [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - [NUnit.Framework.TestFixtureAttribute()] - [NUnit.Framework.DescriptionAttribute("ParsingS7VariableName")] - public partial class ParsingS7VariableNameFeature - { - - private TechTalk.SpecFlow.ITestRunner testRunner; - -#line 1 "ParsingS7VariableName.feature" -#line hidden - - [NUnit.Framework.OneTimeSetUpAttribute()] - public virtual void FeatureSetup() - { - testRunner = TechTalk.SpecFlow.TestRunnerManager.GetTestRunner(); - TechTalk.SpecFlow.FeatureInfo featureInfo = new TechTalk.SpecFlow.FeatureInfo(new System.Globalization.CultureInfo("en-US"), "ParsingS7VariableName", null, ProgrammingLanguage.CSharp, ((string[])(null))); - testRunner.OnFeatureStart(featureInfo); - } - - [NUnit.Framework.OneTimeTearDownAttribute()] - public virtual void FeatureTearDown() - { - testRunner.OnFeatureEnd(); - testRunner = null; - } - - [NUnit.Framework.SetUpAttribute()] - public virtual void TestInitialize() - { - } - - [NUnit.Framework.TearDownAttribute()] - public virtual void ScenarioTearDown() - { - testRunner.OnScenarioEnd(); - } - - public virtual void ScenarioInitialize(TechTalk.SpecFlow.ScenarioInfo scenarioInfo) - { - testRunner.OnScenarioInitialize(scenarioInfo); - testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs(NUnit.Framework.TestContext.CurrentContext); - } - - public virtual void ScenarioStart() - { - testRunner.OnScenarioStart(); - } - - public virtual void ScenarioCleanup() - { - testRunner.CollectScenarioErrors(); - } - - [NUnit.Framework.TestAttribute()] - [NUnit.Framework.DescriptionAttribute("Parsing variable name for bool")] - [NUnit.Framework.CategoryAttribute("mytag")] - public virtual void ParsingVariableNameForBool() - { - TechTalk.SpecFlow.ScenarioInfo scenarioInfo = new TechTalk.SpecFlow.ScenarioInfo("Parsing variable name for bool", null, new string[] { - "mytag"}); -#line 4 -this.ScenarioInitialize(scenarioInfo); - this.ScenarioStart(); -#line 5 - testRunner.Given("I have an Parser", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "Given "); -#line hidden - TechTalk.SpecFlow.Table table1 = new TechTalk.SpecFlow.Table(new string[] { - "VarName"}); - table1.AddRow(new string[] { - "DB13.DBX3.1"}); - table1.AddRow(new string[] { - "Db403.X5.2"}); - table1.AddRow(new string[] { - "DB55DBX23.6"}); - table1.AddRow(new string[] { - "DB1.S255"}); - table1.AddRow(new string[] { - "DB1.S255.20"}); - table1.AddRow(new string[] { - "DB5.String887.20"}); - table1.AddRow(new string[] { - "DB506.B216"}); - table1.AddRow(new string[] { - "DB506.DBB216.5"}); - table1.AddRow(new string[] { - "DB506.D216"}); - table1.AddRow(new string[] { - "DB506.DINT216"}); - table1.AddRow(new string[] { - "DB506.INT216"}); - table1.AddRow(new string[] { - "DB506.DBW216"}); - table1.AddRow(new string[] { - "DB506.DUL216"}); - table1.AddRow(new string[] { - "DB506.DULINT216"}); - table1.AddRow(new string[] { - "DB506.DULONG216"}); -#line 6 - testRunner.And("I have the following variables", ((string)(null)), table1, "And "); -#line 23 - testRunner.When("I parse the var name", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "When "); -#line hidden - TechTalk.SpecFlow.Table table2 = new TechTalk.SpecFlow.Table(new string[] { - "Operand", - "DbNr", - "Start", - "Length", - "Bit", - "Type"}); - table2.AddRow(new string[] { - "Db", - "13", - "3", - "1", - "1", - "Bit"}); - table2.AddRow(new string[] { - "Db", - "403", - "5", - "1", - "2", - "Bit"}); - table2.AddRow(new string[] { - "Db", - "55", - "23", - "1", - "6", - "Bit"}); - table2.AddRow(new string[] { - "Db", - "1", - "255", - "0", - "0", - "String"}); - table2.AddRow(new string[] { - "Db", - "1", - "255", - "20", - "0", - "String"}); - table2.AddRow(new string[] { - "Db", - "5", - "887", - "20", - "0", - "String"}); - table2.AddRow(new string[] { - "Db", - "506", - "216", - "1", - "0", - "Byte"}); - table2.AddRow(new string[] { - "Db", - "506", - "216", - "5", - "0", - "Byte"}); - table2.AddRow(new string[] { - "Db", - "506", - "216", - "4", - "0", - "Double"}); - table2.AddRow(new string[] { - "Db", - "506", - "216", - "4", - "0", - "DInteger"}); - table2.AddRow(new string[] { - "Db", - "506", - "216", - "2", - "0", - "Integer"}); - table2.AddRow(new string[] { - "Db", - "506", - "216", - "2", - "0", - "Integer"}); - table2.AddRow(new string[] { - "Db", - "506", - "216", - "8", - "0", - "ULong"}); - table2.AddRow(new string[] { - "Db", - "506", - "216", - "8", - "0", - "ULong"}); - table2.AddRow(new string[] { - "Db", - "506", - "216", - "8", - "0", - "ULong"}); -#line 24 - testRunner.Then("the result should be", ((string)(null)), table2, "Then "); -#line hidden - this.ScenarioCleanup(); - } - } -} -#pragma warning restore -#endregion diff --git a/Sharp7.Rx.Tests/Sharp7.Rx.Tests.csproj b/Sharp7.Rx.Tests/Sharp7.Rx.Tests.csproj index 39dae7a..2c16b68 100644 --- a/Sharp7.Rx.Tests/Sharp7.Rx.Tests.csproj +++ b/Sharp7.Rx.Tests/Sharp7.Rx.Tests.csproj @@ -1,32 +1,28 @@  - net461 + net8.0 + 12.0 + enable + enable + latest-Recommended + + + $(NoWarn);CA1859;CA1852 - - - + + + + - - - True - True - ParsingS7VariableName.feature - - - - - - SpecFlowSingleFileGenerator - ParsingS7VariableName.feature.cs - - - diff --git a/Sharp7.Rx.Tests/ValueConverterTests/ConvertBothWays.cs b/Sharp7.Rx.Tests/ValueConverterTests/ConvertBothWays.cs new file mode 100644 index 0000000..39c9be3 --- /dev/null +++ b/Sharp7.Rx.Tests/ValueConverterTests/ConvertBothWays.cs @@ -0,0 +1,25 @@ +using NUnit.Framework; +using Shouldly; + +namespace Sharp7.Rx.Tests.ValueConverterTests; + +[TestFixture] +internal class ConvertBothWays : ConverterTestBase +{ + [TestCaseSource(nameof(GetValidTestCases))] + public void Convert(ConverterTestCase tc) + { + //Arrange + var buffer = new byte[tc.VariableAddress.BufferLength]; + + var write = CreateWriteMethod(tc); + var read = CreateReadMethod(tc); + + //Act + write.Invoke(null, [buffer, tc.Value, tc.VariableAddress]); + var result = read.Invoke(null, [buffer, tc.VariableAddress]); + + //Assert + result.ShouldBe(tc.Value); + } +} diff --git a/Sharp7.Rx.Tests/ValueConverterTests/ConverterTestBase.cs b/Sharp7.Rx.Tests/ValueConverterTests/ConverterTestBase.cs new file mode 100644 index 0000000..31dfccb --- /dev/null +++ b/Sharp7.Rx.Tests/ValueConverterTests/ConverterTestBase.cs @@ -0,0 +1,90 @@ +using System.Reflection; +using Sharp7.Rx.Interfaces; + +namespace Sharp7.Rx.Tests.ValueConverterTests; + +internal abstract class ConverterTestBase +{ + protected static readonly IVariableNameParser Parser = new VariableNameParser(); + + public static MethodInfo CreateReadMethod(ConverterTestCase tc) + { + var convertMi = typeof(ConverterTestBase).GetMethod(nameof(ReadFromBuffer)); + var convert = convertMi!.MakeGenericMethod(tc.Value.GetType()); + return convert; + } + + public static MethodInfo CreateWriteMethod(ConverterTestCase tc) + { + var writeMi = typeof(ConverterTestBase).GetMethod(nameof(WriteToBuffer)); + var write = writeMi!.MakeGenericMethod(tc.Value.GetType()); + return write; + } + + public static IEnumerable GetValidTestCases() + { + yield return new ConverterTestCase(true, "DB99.bit5.4", [0x10]); + yield return new ConverterTestCase(false, "DB99.bit5.4", [0x00]); + + yield return new ConverterTestCase((byte) 18, "DB99.Byte5", [0x12]); + yield return new ConverterTestCase((short) 4660, "DB99.Int5", [0x12, 0x34]); + yield return new ConverterTestCase((short) -3532, "DB99.Int5", [0xF2, 0x34]); + yield return new ConverterTestCase((ushort) 4660, "DB99.UInt5", [0x12, 0x34]); + yield return new ConverterTestCase((ushort) 62004, "DB99.UInt5", [0xF2, 0x34]); + yield return new ConverterTestCase(305419879, "DB99.DInt5", [0x12, 0x34, 0x56, 0x67]); + yield return new ConverterTestCase(-231451033, "DB99.DInt5", [0xF2, 0x34, 0x56, 0x67]); + yield return new ConverterTestCase(305419879u, "DB99.UDInt5", [0x12, 0x34, 0x56, 0x67]); + yield return new ConverterTestCase(4063516263u, "DB99.UDInt5", [0xF2, 0x34, 0x56, 0x67]); + yield return new ConverterTestCase(1311768394163015151L, "DB99.LInt5", [0x12, 0x34, 0x56, 0x67, 0x89, 0xAB, 0xCD, 0xEF]); + yield return new ConverterTestCase(-994074615050678801L, "DB99.LInt5", [0xF2, 0x34, 0x56, 0x67, 0x89, 0xAB, 0xCD, 0xEF]); + yield return new ConverterTestCase(1311768394163015151uL, "DB99.ULInt5", [0x12, 0x34, 0x56, 0x67, 0x89, 0xAB, 0xCD, 0xEF]); + yield return new ConverterTestCase(17452669458658872815uL, "DB99.ULInt5", [0xF2, 0x34, 0x56, 0x67, 0x89, 0xAB, 0xCD, 0xEF]); + yield return new ConverterTestCase(0.25f, "DB99.Real5", [0x3E, 0x80, 0x00, 0x00]); + yield return new ConverterTestCase(0.25, "DB99.LReal5", [0x3F, 0xD0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]); + + yield return new ConverterTestCase(new byte[] {0x12, 0x34, 0x56, 0x67}, "DB99.Byte5.4", [0x12, 0x34, 0x56, 0x67]); + + yield return new ConverterTestCase("ABCD", "DB99.String10.4", [0x04, 0x04, 0x41, 0x42, 0x43, 0x44]); + yield return new ConverterTestCase("ABCD", "DB99.String10.6", [0x06, 0x04, 0x41, 0x42, 0x43, 0x44, 0x00, 0x00]); + yield return new ConverterTestCase("ABCD", "DB99.WString10.4", [0x00, 0x04, 0x00, 0x04, 0x00, 0x41, 0x00, 0x42, 0x00, 0x43, 0x00, 0x44]); + yield return new ConverterTestCase("ABCD", "DB99.WString10.6", [0x00, 0x06, 0x00, 0x04, 0x00, 0x41, 0x00, 0x42, 0x00, 0x43, 0x00, 0x44, 0x00, 0x00, 0x00, 0x00]); + yield return new ConverterTestCase("ABCD", "DB99.Byte5.4", [0x41, 0x42, 0x43, 0x44]); + yield return new ConverterTestCase("A\ud83d\udc69\ud83c\udffd\u200d\ud83d\ude80A", "DB99.WString10.10", + [0x0, 0xA, 0x0, 0x9, 0x0, 0x41, 0xD8, 0x3D, 0xDC, 0x69, 0xD8, 0x3C, 0xDF, 0xFD, 0x20, 0xD, 0xD8, 0x3D, 0xDE, 0x80, 0x0, 0x41, 0x0, 0x0]); + + yield return new ConverterTestCase(true, "DB99.DBx0.0", [0x01]); + yield return new ConverterTestCase(false, "DB99.DBx0.0", [0x00]); + yield return new ConverterTestCase(true, "DB99.DBx0.4", [0x10]); + yield return new ConverterTestCase(false, "DB99.DBx0.4", [0]); + yield return new ConverterTestCase((byte) 18, "DB99.DBB0", [0x12]); + yield return new ConverterTestCase((short) 4660, "DB99.INT0", [0x12, 0x34]); + yield return new ConverterTestCase((short) -3532, "DB99.INT0", [0xF2, 0x34]); + yield return new ConverterTestCase(305419879, "DB99.DINT0", [0x12, 0x34, 0x56, 0x67]); + yield return new ConverterTestCase(-231451033, "DB99.DINT0", [0xF2, 0x34, 0x56, 0x67]); + yield return new ConverterTestCase(1311768394163015151uL, "DB99.dul0", [0x12, 0x34, 0x56, 0x67, 0x89, 0xAB, 0xCD, 0xEF]); + yield return new ConverterTestCase(17452669458658872815uL, "DB99.dul0", [0xF2, 0x34, 0x56, 0x67, 0x89, 0xAB, 0xCD, 0xEF]); + yield return new ConverterTestCase(new byte[] {0x12, 0x34, 0x56, 0x67}, "DB99.DBB0.4", [0x12, 0x34, 0x56, 0x67]); + yield return new ConverterTestCase(0.25f, "DB99.D0", [0x3E, 0x80, 0x00, 0x00]); + } + + /// + /// This helper method exists, since I could not manage to invoke a generic method + /// with a Span<T> parameter. + /// + public static TValue ReadFromBuffer(byte[] buffer, VariableAddress address) => + ValueConverter.ReadFromBuffer(buffer, address); + + /// + /// This helper method exists, since I could not manage to invoke a generic method + /// with a Span<T> parameter. + /// + public static void WriteToBuffer(byte[] buffer, TValue value, VariableAddress address) => + ValueConverter.WriteToBuffer(buffer, value, address); + + public record ConverterTestCase(object Value, string Address, byte[] Data) + { + public VariableAddress VariableAddress => Parser.Parse(Address); + + public override string ToString() => $"{Value.GetType().Name}, {Address}: {Value}"; + } +} diff --git a/Sharp7.Rx.Tests/ValueConverterTests/ReadFromBuffer.cs b/Sharp7.Rx.Tests/ValueConverterTests/ReadFromBuffer.cs new file mode 100644 index 0000000..66f08aa --- /dev/null +++ b/Sharp7.Rx.Tests/ValueConverterTests/ReadFromBuffer.cs @@ -0,0 +1,51 @@ +using NUnit.Framework; +using Shouldly; + +namespace Sharp7.Rx.Tests.ValueConverterTests; + +[TestFixture] +internal class ReadFromBuffer : ConverterTestBase +{ + [TestCaseSource(nameof(GetValidTestCases))] + [TestCaseSource(nameof(GetAdditinalReadTestCases))] + public void Read(ConverterTestCase tc) + { + //Arrange + var convert = CreateReadMethod(tc); + + //Act + var result = convert.Invoke(null, [tc.Data, tc.VariableAddress]); + + //Assert + result.ShouldBe(tc.Value); + } + + public static IEnumerable GetAdditinalReadTestCases() + { + yield return new ConverterTestCase(true, "DB0.DBx0.4", [0x1F]); + yield return new ConverterTestCase(false, "DB0.DBx0.4", [0xEF]); + yield return new ConverterTestCase("ABCD", "DB0.string0.6", [0x04, 0x04, 0x41, 0x42, 0x43, 0x44, 0x00, 0x00]); // Length in address exceeds PLC string length + } + + [TestCase((char) 18, "DB0.DBB0", new byte[] {0x12})] + public void UnsupportedType(T template, string address, byte[] data) + { + //Arrange + var variableAddress = Parser.Parse(address); + + //Act + Should.Throw(() => ValueConverter.ReadFromBuffer(data, variableAddress)); + } + + [TestCase(123, "DB12.DINT3", new byte[] {0x01, 0x02, 0x03})] + [TestCase((short) 123, "DB12.INT3", new byte[] {0xF2})] + [TestCase("ABC", "DB0.string0.6", new byte[] {0x01, 0x02, 0x03})] + public void BufferTooSmall(T template, string address, byte[] data) + { + //Arrange + var variableAddress = Parser.Parse(address); + + //Act + Should.Throw(() => ValueConverter.ReadFromBuffer(data, variableAddress)); + } +} diff --git a/Sharp7.Rx.Tests/ValueConverterTests/WriteToBuffer.cs b/Sharp7.Rx.Tests/ValueConverterTests/WriteToBuffer.cs new file mode 100644 index 0000000..b316ae0 --- /dev/null +++ b/Sharp7.Rx.Tests/ValueConverterTests/WriteToBuffer.cs @@ -0,0 +1,76 @@ +using NUnit.Framework; +using Shouldly; + +namespace Sharp7.Rx.Tests.ValueConverterTests; + +[TestFixture] +internal class WriteToBuffer : ConverterTestBase +{ + [TestCaseSource(nameof(GetValidTestCases))] + [TestCaseSource(nameof(GetAdditinalWriteTestCases))] + public void Write(ConverterTestCase tc) + { + //Arrange + var buffer = new byte[tc.VariableAddress.BufferLength]; + var write = CreateWriteMethod(tc); + + //Act + write.Invoke(null, [buffer, tc.Value, tc.VariableAddress]); + + //Assert + buffer.ShouldBe(tc.Data); + } + + public static IEnumerable GetAdditinalWriteTestCases() + { + yield return new ConverterTestCase("a", "DB0.Byte80.3", [0x61, 0x00, 0x00]); // short string + yield return new ConverterTestCase("abc", "DB0.Byte80.3", [0x61, 0x62, 0x63]); // matching string + yield return new ConverterTestCase("abcxx", "DB0.Byte80.3", [0x61, 0x62, 0x63]); // long string + + yield return new ConverterTestCase("a", "DB0.string0.3", [0x03, 0x01, 0x61, 0x00, 0x00]); // short string + yield return new ConverterTestCase("abc", "DB0.string0.3", [0x03, 0x03, 0x61, 0x62, 0x63]); // matching string + yield return new ConverterTestCase("abcxx", "DB0.string0.3", [0x03, 0x03, 0x61, 0x62, 0x63]); // long string + + yield return new ConverterTestCase("a", "DB0.wstring0.3", [0x00, 0x03, 0x00, 0x01, 0x00, 0x61, 0x00, 0x00, 0x00, 0x00]); // short string + yield return new ConverterTestCase("abc", "DB0.wstring0.3", [0x00, 0x03, 0x00, 0x03, 0x00, 0x61, 0x00, 0x62, 0x00, 0x63]); // matching string + yield return new ConverterTestCase("abcxx", "DB0.wstring0.3", [0x00, 0x03, 0x00, 0x03, 0x00, 0x61, 0x00, 0x62, 0x00, 0x63]); // long string + + + yield return new ConverterTestCase("aaaaBCDE", "DB0.string0.4", [0x04, 0x04, 0x61, 0x61, 0x61, 0x61]); // Length in address exceeds PLC string length + yield return new ConverterTestCase("aaaaBCDE", "DB0.WString0.4", [0x00, 0x04, 0x00, 0x04, 0x00, 0x61, 0x00, 0x61, 0x00, 0x61, 0x00, 0x61]); // Length in address exceeds PLC string length + yield return new ConverterTestCase("aaaaBCDE", "DB0.DBB0.4", [0x61, 0x61, 0x61, 0x61]); // Length in address exceeds PLC array length + + // Length in address exceeds PLC string length, multi char unicode point + yield return new ConverterTestCase("\ud83d\udc69\ud83c\udffd\u200d\ud83d\ude80", "DB0.WString0.2", [0x00, 0x02, 0x00, 0x02, 0xD8, 0x3D, 0xDC, 0x69]); + + // Length in address exceeds PLC string length, multi char unicode point + yield return new ConverterTestCase("\ud83d\udc69\ud83c\udffd\u200d\ud83d\ude80", "DB0.String0.2", [0x02, 0x02, 0x3F, 0x3F]); + + // Length in address exceeds PLC string length, multi char unicode point + yield return new ConverterTestCase("\ud83d\udc69\ud83c\udffd\u200d\ud83d\ude80", "DB0.DBB0.4", [0x3F, 0x3F, 0x3F, 0x3F]); + } + + [TestCase(18, "DB0.DInt12", 3)] + [TestCase(0.25f, "DB0.Real1", 3)] + [TestCase("test", "DB0.String1.10", 9)] + public void BufferToSmall(T input, string address, int bufferSize) + { + //Arrange + var variableAddress = Parser.Parse(address); + var buffer = new byte[bufferSize]; + + //Act + Should.Throw(() => ValueConverter.WriteToBuffer(buffer, input, variableAddress)); + } + + [TestCase((char) 18, "DB0.DBB0")] + public void UnsupportedType(T input, string address) + { + //Arrange + var variableAddress = Parser.Parse(address); + var buffer = new byte[variableAddress.BufferLength]; + + //Act + Should.Throw(() => ValueConverter.WriteToBuffer(buffer, input, variableAddress)); + } +} diff --git a/Sharp7.Rx.Tests/VariableAddressTests/MatchesType.cs b/Sharp7.Rx.Tests/VariableAddressTests/MatchesType.cs new file mode 100644 index 0000000..0feffb3 --- /dev/null +++ b/Sharp7.Rx.Tests/VariableAddressTests/MatchesType.cs @@ -0,0 +1,85 @@ +using NUnit.Framework; +using Sharp7.Rx.Extensions; +using Sharp7.Rx.Interfaces; +using Sharp7.Rx.Tests.ValueConverterTests; +using Shouldly; + +namespace Sharp7.Rx.Tests.VariableAddressTests; + +[TestFixture] +public class MatchesType +{ + static readonly IVariableNameParser parser = new VariableNameParser(); + + private static readonly IReadOnlyList typeList = new[] + { + typeof(byte), + typeof(byte[]), + + typeof(bool), + typeof(short), + typeof(ushort), + typeof(int), + typeof(uint), + typeof(long), + typeof(ulong), + + typeof(float), + typeof(double), + + typeof(string), + + typeof(int[]), + typeof(float[]), + typeof(DateTime[]), + typeof(object), + }; + + [TestCaseSource(nameof(GetValid))] + public void Supported(TestCase tc) => Check(tc.Type, tc.Address, true); + + [TestCaseSource(nameof(GetInvalid))] + public void Unsupported(TestCase tc) => Check(tc.Type, tc.Address, false); + + + public static IEnumerable GetValid() + { + return + ConverterTestBase.GetValidTestCases() + .Select(tc => new TestCase(tc.Value.GetType(), tc.Address)); + } + + public static IEnumerable GetInvalid() + { + return + ConverterTestBase.GetValidTestCases() + .DistinctBy(tc => tc.Value.GetType()) + .SelectMany(tc => + typeList.Where(type => type != tc.Value.GetType()) + .Select(type => new TestCase(type, tc.Address)) + ) + + // Explicitly remove some valid combinations + .Where(tc => !( + (tc.Type == typeof(string) && tc.Address == "DB99.Byte5") || + (tc.Type == typeof(string) && tc.Address == "DB99.Byte5.4") || + (tc.Type == typeof(byte[]) && tc.Address == "DB99.Byte5") + )) + ; + } + + + private static void Check(Type type, string address, bool expected) + { + //Arrange + var variableAddress = parser.Parse(address); + + //Act + variableAddress.MatchesType(type).ShouldBe(expected); + } + + public record TestCase(Type Type, string Address) + { + public override string ToString() => $"{Type.Name} {Address}"; + } +} diff --git a/Sharp7.Rx.Tests/VariableNameParserTests.cs b/Sharp7.Rx.Tests/VariableNameParserTests.cs new file mode 100644 index 0000000..c3e0405 --- /dev/null +++ b/Sharp7.Rx.Tests/VariableNameParserTests.cs @@ -0,0 +1,89 @@ +using DeepEqual.Syntax; +using NUnit.Framework; +using Sharp7.Rx.Enums; +using Shouldly; + +namespace Sharp7.Rx.Tests; + +[TestFixture] +internal class VariableNameParserTests +{ + [TestCaseSource(nameof(ValidTestCases))] + public void Run(TestCase tc) + { + var parser = new VariableNameParser(); + var resp = parser.Parse(tc.Input); + resp.ShouldDeepEqual(tc.Expected); + } + + [TestCase("DB506.Bit216", TestName = "Bit without Bit")] + [TestCase("DB506.Bit216.8", TestName = "Bit to high")] + [TestCase("DB506.String216", TestName = "String without Length")] + [TestCase("DB506.WString216", TestName = "WString without Length")] + [TestCase("DB506.Int216.1", TestName = "Int with Length")] + [TestCase("DB506.UInt216.1", TestName = "UInt with Length")] + [TestCase("DB506.DInt216.1", TestName = "DInt with Length")] + [TestCase("DB506.UDInt216.1", TestName = "UDInt with Length")] + [TestCase("DB506.LInt216.1", TestName = "LInt with Length")] + [TestCase("DB506.ULInt216.1", TestName = "ULInt with Length")] + [TestCase("DB506.Real216.1", TestName = "LReal with Length")] + [TestCase("DB506.LReal216.1", TestName = "LReal with Length")] + [TestCase("DB506.xx216", TestName = "Invalid type")] + [TestCase("DB506.216", TestName = "No type")] + [TestCase("DB506.Int216.", TestName = "Trailing dot")] + [TestCase("x506.Int216", TestName = "Wrong type")] + [TestCase("506.Int216", TestName = "No type")] + [TestCase("", TestName = "empty")] + [TestCase(" ", TestName = "space")] + [TestCase(" DB506.Int216", TestName = "leading space")] + [TestCase("DB506.Int216 ", TestName = "trailing space")] + [TestCase("DB.Int216 ", TestName = "No db")] + [TestCase("DB5061234.Int216.1", TestName = "DB too large")] + public void Invalid(string? input) + { + var parser = new VariableNameParser(); + Should.Throw(() => parser.Parse(input!)); + } + + public static IEnumerable ValidTestCases() + { + yield return new TestCase("DB506.Bit216.2", new VariableAddress(Operand: Operand.Db, DbNo: 506, Type: DbType.Bit, Start: 216, Length: 1, Bit: 2)); + + yield return new TestCase("DB506.String216.10", new VariableAddress(Operand: Operand.Db, DbNo: 506, Type: DbType.String, Start: 216, Length: 10)); + yield return new TestCase("DB506.WString216.10", new VariableAddress(Operand: Operand.Db, DbNo: 506, Type: DbType.WString, Start: 216, Length: 10)); + + yield return new TestCase("DB506.Byte216", new VariableAddress(Operand: Operand.Db, DbNo: 506, Type: DbType.Byte, Start: 216, Length: 1)); + yield return new TestCase("DB506.Byte216.100", new VariableAddress(Operand: Operand.Db, DbNo: 506, Type: DbType.Byte, Start: 216, Length: 100)); + yield return new TestCase("DB506.Int216", new VariableAddress(Operand: Operand.Db, DbNo: 506, Type: DbType.Int, Start: 216, Length: 2)); + yield return new TestCase("DB506.UInt216", new VariableAddress(Operand: Operand.Db, DbNo: 506, Type: DbType.UInt, Start: 216, Length: 2)); + yield return new TestCase("DB506.DInt216", new VariableAddress(Operand: Operand.Db, DbNo: 506, Type: DbType.DInt, Start: 216, Length: 4)); + yield return new TestCase("DB506.UDInt216", new VariableAddress(Operand: Operand.Db, DbNo: 506, Type: DbType.UDInt, Start: 216, Length: 4)); + yield return new TestCase("DB506.LInt216", new VariableAddress(Operand: Operand.Db, DbNo: 506, Type: DbType.LInt, Start: 216, Length: 8)); + yield return new TestCase("DB506.ULInt216", new VariableAddress(Operand: Operand.Db, DbNo: 506, Type: DbType.ULInt, Start: 216, Length: 8)); + + yield return new TestCase("DB506.Real216", new VariableAddress(Operand: Operand.Db, DbNo: 506, Type: DbType.Single, Start: 216, Length: 4)); + yield return new TestCase("DB506.LReal216", new VariableAddress(Operand: Operand.Db, DbNo: 506, Type: DbType.Double, Start: 216, Length: 8)); + + + // Legacy + yield return new TestCase("DB13.DBX3.1", new VariableAddress(Operand: Operand.Db, DbNo: 13, Type: DbType.Bit, Start: 3, Length: 1, Bit: 1)); + yield return new TestCase("Db403.X5.2", new VariableAddress(Operand: Operand.Db, DbNo: 403, Type: DbType.Bit, Start: 5, Length: 1, Bit: 2)); + yield return new TestCase("DB55DBX23.6", new VariableAddress(Operand: Operand.Db, DbNo: 55, Type: DbType.Bit, Start: 23, Length: 1, Bit: 6)); + yield return new TestCase("DB1.S255.20", new VariableAddress(Operand: Operand.Db, DbNo: 1, Type: DbType.String, Start: 255, Length: 20)); + yield return new TestCase("DB5.String887.20", new VariableAddress(Operand: Operand.Db, DbNo: 5, Type: DbType.String, Start: 887, Length: 20)); + yield return new TestCase("DB506.B216", new VariableAddress(Operand: Operand.Db, DbNo: 506, Type: DbType.Byte, Start: 216, Length: 1)); + yield return new TestCase("DB506.DBB216.5", new VariableAddress(Operand: Operand.Db, DbNo: 506, Type: DbType.Byte, Start: 216, Length: 5)); + yield return new TestCase("DB506.D216", new VariableAddress(Operand: Operand.Db, DbNo: 506, Type: DbType.Single, Start: 216, Length: 4)); + yield return new TestCase("DB506.DINT216", new VariableAddress(Operand: Operand.Db, DbNo: 506, Type: DbType.DInt, Start: 216, Length: 4)); + yield return new TestCase("DB506.INT216", new VariableAddress(Operand: Operand.Db, DbNo: 506, Type: DbType.Int, Start: 216, Length: 2)); + yield return new TestCase("DB506.DBW216", new VariableAddress(Operand: Operand.Db, DbNo: 506, Type: DbType.Int, Start: 216, Length: 2)); + yield return new TestCase("DB506.DUL216", new VariableAddress(Operand: Operand.Db, DbNo: 506, Type: DbType.ULInt, Start: 216, Length: 8)); + yield return new TestCase("DB506.DULINT216", new VariableAddress(Operand: Operand.Db, DbNo: 506, Type: DbType.ULInt, Start: 216, Length: 8)); + yield return new TestCase("DB506.DULONG216", new VariableAddress(Operand: Operand.Db, DbNo: 506, Type: DbType.ULInt, Start: 216, Length: 8)); + } + + public record TestCase(string Input, VariableAddress Expected) + { + public override string ToString() => Input; + } +} diff --git a/Sharp7.Rx.sln b/Sharp7.Rx.sln index 033c14e..e211d65 100644 --- a/Sharp7.Rx.sln +++ b/Sharp7.Rx.sln @@ -1,11 +1,23 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.28010.2041 +# Visual Studio Version 17 +VisualStudioVersion = 17.9.34728.123 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sharp7.Rx", "Sharp7.Rx\Sharp7.Rx.csproj", "{690A7E0E-BE95-49AC-AF2F-7FEA2F63204A}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sharp7.Rx.Tests", "Sharp7.Rx.Tests\Sharp7.Rx.Tests.csproj", "{1BDD07D2-6540-4ACF-81E7-98300421073B}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sharp7.Rx.Tests", "Sharp7.Rx.Tests\Sharp7.Rx.Tests.csproj", "{1BDD07D2-6540-4ACF-81E7-98300421073B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_Solution Items", "_Solution Items", "{3A9DEBA7-8F53-4554-869C-7C99F0A4932E}" + ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig + .gitignore = .gitignore + README.md = README.md + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Workflow", "Workflow", "{1CFDA2EA-49CF-4B96-A9C9-B12B21B3D78E}" + ProjectSection(SolutionItems) = preProject + .github\workflows\release.yml = .github\workflows\release.yml + EndProjectSection EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -25,6 +37,9 @@ Global GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {1CFDA2EA-49CF-4B96-A9C9-B12B21B3D78E} = {3A9DEBA7-8F53-4554-869C-7C99F0A4932E} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {ABA1FD47-15EE-4588-9BA7-0116C635BFC4} EndGlobalSection diff --git a/Sharp7.Rx/AssemblyInfo.cs b/Sharp7.Rx/AssemblyInfo.cs index 9d8031c..d9d730c 100644 --- a/Sharp7.Rx/AssemblyInfo.cs +++ b/Sharp7.Rx/AssemblyInfo.cs @@ -1,4 +1,3 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Sharp7.Rx.Tests")] - diff --git a/Sharp7.Rx/Basics/ConcurrentSubjectDictionary.cs b/Sharp7.Rx/Basics/ConcurrentSubjectDictionary.cs index fa5184e..332e03c 100644 --- a/Sharp7.Rx/Basics/ConcurrentSubjectDictionary.cs +++ b/Sharp7.Rx/Basics/ConcurrentSubjectDictionary.cs @@ -1,128 +1,130 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; +using System.Collections.Concurrent; using System.Reactive; using System.Reactive.Linq; using System.Reactive.Subjects; -using Sharp7.Rx.Extensions; -namespace Sharp7.Rx.Basics +namespace Sharp7.Rx.Basics; + +internal class ConcurrentSubjectDictionary : IDisposable { - internal class ConcurrentSubjectDictionary : IDisposable + private readonly object dictionaryLock = new object(); + private readonly Func valueFactory; + private ConcurrentDictionary dictionary; + + public ConcurrentSubjectDictionary() { - private readonly object dictionaryLock = new object(); - private readonly Func valueFactory; - private ConcurrentDictionary dictionary; + dictionary = new ConcurrentDictionary(); + } - public ConcurrentSubjectDictionary() + public ConcurrentSubjectDictionary(IEqualityComparer comparer) + { + dictionary = new ConcurrentDictionary(comparer); + } + + public ConcurrentSubjectDictionary(TValue initialValue, IEqualityComparer comparer) + { + valueFactory = _ => initialValue; + dictionary = new ConcurrentDictionary(comparer); + } + + public ConcurrentSubjectDictionary(TValue initialValue) + { + valueFactory = _ => initialValue; + dictionary = new ConcurrentDictionary(); + } + + public ConcurrentSubjectDictionary(Func valueFactory = null) + { + this.valueFactory = valueFactory; + dictionary = new ConcurrentDictionary(); + } + + public IEnumerable ExistingKeys => dictionary.Keys; + + public bool IsDisposed { get; private set; } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + public DisposableItem GetOrCreateObservable(TKey key) + { + lock (dictionaryLock) { - dictionary = new ConcurrentDictionary(); - } - - public ConcurrentSubjectDictionary(IEqualityComparer comparer) - { - dictionary = new ConcurrentDictionary(comparer); - } - - public ConcurrentSubjectDictionary(TValue initialValue, IEqualityComparer comparer) - { - valueFactory = _ => initialValue; - dictionary = new ConcurrentDictionary(comparer); - } - - public ConcurrentSubjectDictionary(TValue initialValue) - { - valueFactory = _ => initialValue; - dictionary = new ConcurrentDictionary(); - } - - public ConcurrentSubjectDictionary(Func valueFactory = null) - { - this.valueFactory = valueFactory; - dictionary = new ConcurrentDictionary(); - } - - public IEnumerable ExistingKeys => dictionary.Keys; - - public bool IsDisposed { get; private set; } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - public DisposableItem GetOrCreateObservable(TKey key) - { - lock (dictionaryLock) - { - var subject = dictionary.AddOrUpdate(key, k => new SubjectWithRefCounter {Counter = 1, Subject = CreateSubject(k)}, (key1, counter) => + var subject = dictionary.AddOrUpdate( + key, + k => new SubjectWithRefCounter(CreateSubject(k)), + (_, subjectWithRefCounter) => { - counter.Counter = counter.Counter + 1; - return counter; + subjectWithRefCounter.IncreaseCount(); + return subjectWithRefCounter; }); - return new DisposableItem(subject.Subject.AsObservable(), () => RemoveIfNoLongerInUse(key)); - } - } - - public bool TryGetObserver(TKey key, out IObserver subject) - { - SubjectWithRefCounter subjectWithRefCount; - if (dictionary.TryGetValue(key, out subjectWithRefCount)) - { - subject = subjectWithRefCount.Subject.AsObserver(); - return true; - } - - subject = null; - return false; - } - - protected virtual void Dispose(bool disposing) - { - if (IsDisposed) - return; - if (disposing && dictionary != null) - { - dictionary.Values.DisposeItems(); - dictionary.Clear(); - dictionary = null; - } - - IsDisposed = true; - } - - private ISubject CreateSubject(TKey key) - { - if (valueFactory == null) - return new Subject(); - return new BehaviorSubject(valueFactory(key)); - } - - private void RemoveIfNoLongerInUse(TKey variableName) - { - lock (dictionaryLock) - { - SubjectWithRefCounter subjectWithRefCount; - if (dictionary.TryGetValue(variableName, out subjectWithRefCount)) - { - if (subjectWithRefCount.Counter == 1) - dictionary.TryRemove(variableName, out subjectWithRefCount); - else subjectWithRefCount.Counter--; - } - } - } - - ~ConcurrentSubjectDictionary() - { - Dispose(false); - } - - class SubjectWithRefCounter - { - public int Counter { get; set; } - public ISubject Subject { get; set; } + return new DisposableItem(subject.Subject.AsObservable(), () => RemoveIfNoLongerInUse(key)); } } -} \ No newline at end of file + + public bool TryGetObserver(TKey key, out IObserver subject) + { + if (dictionary.TryGetValue(key, out var subjectWithRefCount)) + { + subject = subjectWithRefCount.Subject.AsObserver(); + return true; + } + + subject = null; + return false; + } + + protected virtual void Dispose(bool disposing) + { + if (IsDisposed) + return; + if (disposing && dictionary != null) + { + foreach (var subjectWithRefCounter in dictionary) + subjectWithRefCounter.Value.Subject.OnCompleted(); + dictionary.Clear(); + dictionary = null; + } + + IsDisposed = true; + } + + private ISubject CreateSubject(TKey key) + { + if (valueFactory == null) + return new Subject(); + return new BehaviorSubject(valueFactory(key)); + } + + private void RemoveIfNoLongerInUse(TKey variableName) + { + lock (dictionaryLock) + if (dictionary.TryGetValue(variableName, out var subjectWithRefCount)) + if (subjectWithRefCount.DecreaseCount() < 1) + dictionary.TryRemove(variableName, out _); + } + + ~ConcurrentSubjectDictionary() + { + Dispose(false); + } + + class SubjectWithRefCounter + { + private int counter = 1; + + public SubjectWithRefCounter(ISubject subject) + { + Subject = subject; + } + + public ISubject Subject { get; } + + public int DecreaseCount() => Interlocked.Decrement(ref counter); + public int IncreaseCount() => Interlocked.Increment(ref counter); + } +} diff --git a/Sharp7.Rx/Basics/DisposableItem.cs b/Sharp7.Rx/Basics/DisposableItem.cs index f6ab006..dd52a88 100644 --- a/Sharp7.Rx/Basics/DisposableItem.cs +++ b/Sharp7.Rx/Basics/DisposableItem.cs @@ -1,38 +1,34 @@ -using System; -using System.Linq; +namespace Sharp7.Rx.Basics; -namespace Sharp7.Rx.Basics +internal class DisposableItem : IDisposable { - internal class DisposableItem : IDisposable + private readonly Action disposeAction; + + bool disposed; + + public DisposableItem(IObservable observable, Action disposeAction) { - private readonly Action disposeAction; - - bool disposed; - - public DisposableItem(IObservable observable, Action disposeAction) - { - this.disposeAction = disposeAction; - Observable = observable; - } - - public IObservable Observable { get; } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - if (disposed) return; - - if (disposing) - { - disposeAction(); - } - - disposed = true; - } + this.disposeAction = disposeAction; + Observable = observable; } -} \ No newline at end of file + + public IObservable Observable { get; } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (disposed) return; + + if (disposing) + { + disposeAction(); + } + + disposed = true; + } +} diff --git a/Sharp7.Rx/Basics/LimitedConcurrencyLevelTaskScheduler.cs b/Sharp7.Rx/Basics/LimitedConcurrencyLevelTaskScheduler.cs index ac1754d..56ca27b 100644 --- a/Sharp7.Rx/Basics/LimitedConcurrencyLevelTaskScheduler.cs +++ b/Sharp7.Rx/Basics/LimitedConcurrencyLevelTaskScheduler.cs @@ -1,146 +1,139 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; +namespace Sharp7.Rx.Basics; -namespace Sharp7.Rx.Basics +/// +/// Provides a task scheduler that ensures a maximum concurrency level while +/// running on top of the ThreadPool. +/// from http://msdn.microsoft.com/en-us/library/ee789351.aspx +/// +internal class LimitedConcurrencyLevelTaskScheduler : TaskScheduler { + /// Whether the current thread is processing work items. + [ThreadStatic] private static bool currentThreadIsProcessingItems; + + /// The maximum concurrency level allowed by this scheduler. + private readonly int maxDegreeOfParallelism; + + /// The list of tasks to be executed. + private readonly LinkedList tasks = new LinkedList(); // protected by lock(_tasks) + + /// Whether the scheduler is currently processing work items. + private int delegatesQueuedOrRunning; // protected by lock(_tasks) + /// - /// Provides a task scheduler that ensures a maximum concurrency level while - /// running on top of the ThreadPool. - /// from http://msdn.microsoft.com/en-us/library/ee789351.aspx + /// Initializes an instance of the LimitedConcurrencyLevelTaskScheduler class with the + /// specified degree of parallelism. /// - internal class LimitedConcurrencyLevelTaskScheduler : TaskScheduler + /// The maximum degree of parallelism provided by this scheduler. + public LimitedConcurrencyLevelTaskScheduler(int maxDegreeOfParallelism) { - /// Whether the current thread is processing work items. - [ThreadStatic] private static bool currentThreadIsProcessingItems; + if (maxDegreeOfParallelism < 1) throw new ArgumentOutOfRangeException(nameof(maxDegreeOfParallelism)); + this.maxDegreeOfParallelism = maxDegreeOfParallelism; + } - /// The maximum concurrency level allowed by this scheduler. - private readonly int maxDegreeOfParallelism; + /// Gets the maximum concurrency level supported by this scheduler. + public sealed override int MaximumConcurrencyLevel => maxDegreeOfParallelism; - /// The list of tasks to be executed. - private readonly LinkedList tasks = new LinkedList(); // protected by lock(_tasks) - - /// Whether the scheduler is currently processing work items. - private int delegatesQueuedOrRunning; // protected by lock(_tasks) - - /// - /// Initializes an instance of the LimitedConcurrencyLevelTaskScheduler class with the - /// specified degree of parallelism. - /// - /// The maximum degree of parallelism provided by this scheduler. - public LimitedConcurrencyLevelTaskScheduler(int maxDegreeOfParallelism) + /// Gets an enumerable of the tasks currently scheduled on this scheduler. + /// An enumerable of the tasks currently scheduled. + protected sealed override IEnumerable GetScheduledTasks() + { + var lockTaken = false; + try { - if (maxDegreeOfParallelism < 1) throw new ArgumentOutOfRangeException("maxDegreeOfParallelism"); - this.maxDegreeOfParallelism = maxDegreeOfParallelism; + Monitor.TryEnter(tasks, ref lockTaken); + if (lockTaken) return tasks.ToArray(); + else throw new NotSupportedException(); } - - /// Gets the maximum concurrency level supported by this scheduler. - public sealed override int MaximumConcurrencyLevel => maxDegreeOfParallelism; - - /// Gets an enumerable of the tasks currently scheduled on this scheduler. - /// An enumerable of the tasks currently scheduled. - protected sealed override IEnumerable GetScheduledTasks() + finally { - var lockTaken = false; - try - { - Monitor.TryEnter(tasks, ref lockTaken); - if (lockTaken) return tasks.ToArray(); - else throw new NotSupportedException(); - } - finally - { - if (lockTaken) Monitor.Exit(tasks); - } - } - - /// Queues a task to the scheduler. - /// The task to be queued. - protected sealed override void QueueTask(Task task) - { - // Add the task to the list of tasks to be processed. If there aren't enough - // delegates currently queued or running to process tasks, schedule another. - lock (tasks) - { - tasks.AddLast(task); - if (delegatesQueuedOrRunning < maxDegreeOfParallelism) - { - ++delegatesQueuedOrRunning; - NotifyThreadPoolOfPendingWork(); - } - } - } - - /// Attempts to remove a previously scheduled task from the scheduler. - /// The task to be removed. - /// Whether the task could be found and removed. - protected sealed override bool TryDequeue(Task task) - { - lock (tasks) - { - return tasks.Remove(task); - } - } - - /// Attempts to execute the specified task on the current thread. - /// The task to be executed. - /// - /// Whether the task could be executed on the current thread. - protected sealed override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued) - { - // If this thread isn't already processing a task, we don't support inlining - if (!currentThreadIsProcessingItems) return false; - - // If the task was previously queued, remove it from the queue - if (taskWasPreviouslyQueued) TryDequeue(task); - - // Try to run the task. - return TryExecuteTask(task); - } - - /// - /// Informs the ThreadPool that there's work to be executed for this scheduler. - /// - private void NotifyThreadPoolOfPendingWork() - { - ThreadPool.UnsafeQueueUserWorkItem(_ => - { - // Note that the current thread is now processing work items. - // This is necessary to enable inlining of tasks into this thread. - currentThreadIsProcessingItems = true; - try - { - // Process all available items in the queue. - while (true) - { - Task item; - lock (tasks) - { - // When there are no more items to be processed, - // note that we're done processing, and get out. - if (tasks.Count == 0) - { - --delegatesQueuedOrRunning; - break; - } - - // Get the next item from the queue - item = tasks.First.Value; - tasks.RemoveFirst(); - } - - // Execute the task we pulled out of the queue - TryExecuteTask(item); - } - } - // We're done processing items on the current thread - finally - { - currentThreadIsProcessingItems = false; - } - }, null); + if (lockTaken) Monitor.Exit(tasks); } } -} \ No newline at end of file + + /// Queues a task to the scheduler. + /// The task to be queued. + protected sealed override void QueueTask(Task task) + { + // Add the task to the list of tasks to be processed. If there aren't enough + // delegates currently queued or running to process tasks, schedule another. + lock (tasks) + { + tasks.AddLast(task); + if (delegatesQueuedOrRunning < maxDegreeOfParallelism) + { + ++delegatesQueuedOrRunning; + NotifyThreadPoolOfPendingWork(); + } + } + } + + /// Attempts to remove a previously scheduled task from the scheduler. + /// The task to be removed. + /// Whether the task could be found and removed. + protected sealed override bool TryDequeue(Task task) + { + lock (tasks) + { + return tasks.Remove(task); + } + } + + /// Attempts to execute the specified task on the current thread. + /// The task to be executed. + /// + /// Whether the task could be executed on the current thread. + protected sealed override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued) + { + // If this thread isn't already processing a task, we don't support inlining + if (!currentThreadIsProcessingItems) return false; + + // If the task was previously queued, remove it from the queue + if (taskWasPreviouslyQueued) TryDequeue(task); + + // Try to run the task. + return TryExecuteTask(task); + } + + /// + /// Informs the ThreadPool that there's work to be executed for this scheduler. + /// + private void NotifyThreadPoolOfPendingWork() + { + ThreadPool.UnsafeQueueUserWorkItem(_ => + { + // Note that the current thread is now processing work items. + // This is necessary to enable inlining of tasks into this thread. + currentThreadIsProcessingItems = true; + try + { + // Process all available items in the queue. + while (true) + { + Task item; + lock (tasks) + { + // When there are no more items to be processed, + // note that we're done processing, and get out. + if (tasks.Count == 0) + { + --delegatesQueuedOrRunning; + break; + } + + // Get the next item from the queue + item = tasks.First.Value; + tasks.RemoveFirst(); + } + + // Execute the task we pulled out of the queue + TryExecuteTask(item); + } + } + // We're done processing items on the current thread + finally + { + currentThreadIsProcessingItems = false; + } + }, null); + } +} diff --git a/Sharp7.Rx/CacheVariableNameParser.cs b/Sharp7.Rx/CacheVariableNameParser.cs new file mode 100644 index 0000000..c4d42a9 --- /dev/null +++ b/Sharp7.Rx/CacheVariableNameParser.cs @@ -0,0 +1,18 @@ +using System.Collections.Concurrent; +using Sharp7.Rx.Interfaces; + +namespace Sharp7.Rx; + +internal class CacheVariableNameParser : IVariableNameParser +{ + private static readonly ConcurrentDictionary addressCache = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + + private readonly IVariableNameParser inner; + + public CacheVariableNameParser(IVariableNameParser inner) + { + this.inner = inner; + } + + public VariableAddress Parse(string input) => addressCache.GetOrAdd(input, inner.Parse); +} diff --git a/Sharp7.Rx/Enums/ConnectionState.cs b/Sharp7.Rx/Enums/ConnectionState.cs index acf93b6..ada7634 100644 --- a/Sharp7.Rx/Enums/ConnectionState.cs +++ b/Sharp7.Rx/Enums/ConnectionState.cs @@ -1,10 +1,10 @@ -namespace Sharp7.Rx.Enums +namespace Sharp7.Rx.Enums; + +public enum ConnectionState { - public enum ConnectionState - { - Initial, - Connected, - DisconnectedByUser, - ConnectionLost - } + Initial, + Connected, + DisconnectedByUser, + ConnectionLost, + Disposed } diff --git a/Sharp7.Rx/Enums/CpuType.cs b/Sharp7.Rx/Enums/CpuType.cs index 28cd172..a923cbc 100644 --- a/Sharp7.Rx/Enums/CpuType.cs +++ b/Sharp7.Rx/Enums/CpuType.cs @@ -1,10 +1,9 @@ -namespace Sharp7.Rx.Enums +namespace Sharp7.Rx.Enums; + +internal enum CpuType { - internal enum CpuType - { - S7_300, - S7_400, - S7_1200, - S7_1500 - } + S7_300, + S7_400, + S7_1200, + S7_1500 } diff --git a/Sharp7.Rx/Enums/DbType.cs b/Sharp7.Rx/Enums/DbType.cs index 66116c8..30691d6 100644 --- a/Sharp7.Rx/Enums/DbType.cs +++ b/Sharp7.Rx/Enums/DbType.cs @@ -1,13 +1,52 @@ -namespace Sharp7.Rx.Enums +namespace Sharp7.Rx.Enums; + +// see https://support.industry.siemens.com/cs/mdm/109747174?c=88343664523&lc=de-DE +internal enum DbType { - internal enum DbType - { - Bit, - String, - Byte, - Double, - Integer, - DInteger, - ULong - } + Bit, + + /// + /// ASCII string + /// + String, + + /// + /// UTF16 string + /// + WString, + + Byte, + + /// + /// Int16 + /// + Int, + + /// + /// UInt16 + /// + UInt, + + /// + /// Int32 + /// + DInt, + + /// + /// UInt32 + /// + UDInt, + + /// + /// Int64 + /// + LInt, + + /// + /// UInt64 + /// + ULInt, + + Single, + Double, } diff --git a/Sharp7.Rx/Enums/Operand.cs b/Sharp7.Rx/Enums/Operand.cs index 79ed488..6ad4970 100644 --- a/Sharp7.Rx/Enums/Operand.cs +++ b/Sharp7.Rx/Enums/Operand.cs @@ -1,10 +1,9 @@ -namespace Sharp7.Rx.Enums +namespace Sharp7.Rx.Enums; + +internal enum Operand : byte { - internal enum Operand : byte - { - Input = 69, - Output = 65, - Marker = 77, - Db = 68, - } + Input = 69, + Output = 65, + Marker = 77, + Db = 68, } diff --git a/Sharp7.Rx/Enums/TransmissionMode.cs b/Sharp7.Rx/Enums/TransmissionMode.cs index 2e9304a..5ba9dc2 100644 --- a/Sharp7.Rx/Enums/TransmissionMode.cs +++ b/Sharp7.Rx/Enums/TransmissionMode.cs @@ -1,8 +1,7 @@ -namespace Sharp7.Rx.Enums +namespace Sharp7.Rx.Enums; + +public enum TransmissionMode { - public enum TransmissionMode - { - Cyclic = 3, - OnChange = 4, - } + Cyclic = 3, + OnChange = 4, } diff --git a/Sharp7.Rx/Exceptions/S7Exception.cs b/Sharp7.Rx/Exceptions/S7Exception.cs new file mode 100644 index 0000000..7fd9915 --- /dev/null +++ b/Sharp7.Rx/Exceptions/S7Exception.cs @@ -0,0 +1,83 @@ +namespace Sharp7.Rx; + +public abstract class S7Exception : Exception +{ + protected S7Exception(string message) : base(message) + { + } + + protected S7Exception(string message, Exception innerException) : base(message, innerException) + { + } +} + +public class S7CommunicationException : S7Exception +{ + public S7CommunicationException(string message, int s7ErrorCode, string s7ErrorText) : base(message) + { + S7ErrorCode = s7ErrorCode; + S7ErrorText = s7ErrorText; + } + + public S7CommunicationException(string message, Exception innerException, int s7ErrorCode, string s7ErrorText) : base(message, innerException) + { + S7ErrorCode = s7ErrorCode; + S7ErrorText = s7ErrorText; + } + + public int S7ErrorCode { get; } + public string S7ErrorText { get; } +} + +public class DataTypeMissmatchException : S7Exception +{ + internal DataTypeMissmatchException(string message, Type type, VariableAddress address) : base(message) + { + Type = type; + Address = address.ToString(); + } + + internal DataTypeMissmatchException(string message, Exception innerException, Type type, VariableAddress address) : base(message, innerException) + { + Type = type; + Address = address.ToString(); + } + + public string Address { get; } + + public Type Type { get; } +} + +public class UnsupportedS7TypeException : S7Exception +{ + internal UnsupportedS7TypeException(string message, Type type, VariableAddress address) : base(message) + { + Type = type; + Address = address.ToString(); + } + + internal UnsupportedS7TypeException(string message, Exception innerException, Type type, VariableAddress address) : base(message, innerException) + { + Type = type; + Address = address.ToString(); + } + + public string Address { get; } + + public Type Type { get; } +} + +public class InvalidS7AddressException : S7Exception +{ + public InvalidS7AddressException(string message, string input) : base(message) + { + Input = input; + } + + public InvalidS7AddressException(string message, Exception innerException, string input) : base(message, innerException) + { + Input = input; + } + + public string Input { get; } +} diff --git a/Sharp7.Rx/Extensions/DisposableExtensions.cs b/Sharp7.Rx/Extensions/DisposableExtensions.cs index 0ad5c06..5638168 100644 --- a/Sharp7.Rx/Extensions/DisposableExtensions.cs +++ b/Sharp7.Rx/Extensions/DisposableExtensions.cs @@ -1,21 +1,11 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reactive.Disposables; +using System.Reactive.Disposables; -namespace Sharp7.Rx.Extensions +namespace Sharp7.Rx.Extensions; + +internal static class DisposableExtensions { - internal static class DisposableExtensions + public static void AddDisposableTo(this IDisposable disposable, CompositeDisposable compositeDisposable) { - public static void AddDisposableTo(this IDisposable disposable, CompositeDisposable compositeDisposable) - { - compositeDisposable.Add(disposable); - } - - public static void DisposeItems(this IEnumerable disposables) - { - foreach (IDisposable disposable in disposables.OfType()) - disposable?.Dispose(); - } + compositeDisposable.Add(disposable); } } diff --git a/Sharp7.Rx/Extensions/ObservableExtensions.cs b/Sharp7.Rx/Extensions/ObservableExtensions.cs index 052b6ee..3c5735b 100644 --- a/Sharp7.Rx/Extensions/ObservableExtensions.cs +++ b/Sharp7.Rx/Extensions/ObservableExtensions.cs @@ -1,91 +1,81 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Linq; -using System.Linq.Expressions; -using System.Reactive; -using System.Reactive.Concurrency; +using System.Reactive.Concurrency; using System.Reactive.Disposables; using System.Reactive.Linq; -using System.Reactive.Subjects; -using System.Threading.Tasks; using Microsoft.Extensions.Logging; -using Sharp7.Rx.Resources; -namespace Sharp7.Rx.Extensions +namespace Sharp7.Rx.Extensions; + +internal static class ObservableExtensions { - internal static class ObservableExtensions - { - public static IObservable LogAndRetry(this IObservable source, ILogger logger, string message) - { - return source - .Do( - _ => { }, - ex => logger?.LogError(ex, message)) - .Retry(); - } + public static IObservable DisposeMany(this IObservable source) + { + return Observable.Create(obs => + { + var serialDisposable = new SerialDisposable(); + var subscription = + source.Subscribe( + item => + { + serialDisposable.Disposable = item as IDisposable; + obs.OnNext(item); + }, + obs.OnError, + obs.OnCompleted); + return new CompositeDisposable(serialDisposable, subscription); + }); + } - public static IObservable RetryAfterDelay( - this IObservable source, - TimeSpan retryDelay, - int retryCount = -1, - IScheduler scheduler = null) - { - return RedoAfterDelay(source, retryDelay, retryCount, scheduler, Observable.Retry, Observable.Retry); - } + public static IObservable LogAndRetry(this IObservable source, ILogger logger, string message) + { + return source + .Do( + _ => { }, + ex => logger?.LogError(ex, message)) + .Retry(); + } - public static IObservable RepeatAfterDelay( - this IObservable source, - TimeSpan retryDelay, - int repeatCount = -1, - IScheduler scheduler = null) - { - return RedoAfterDelay(source, retryDelay, repeatCount, scheduler, Observable.Repeat, Observable.Repeat); - } + public static IObservable LogAndRetryAfterDelay( + this IObservable source, + ILogger logger, + TimeSpan retryDelay, + string message, + int retryCount = -1, + IScheduler scheduler = null) + { + var sourceLogged = + source + .Do( + _ => { }, + ex => logger?.LogError(ex, message)); - public static IObservable LogAndRetryAfterDelay( - this IObservable source, - ILogger logger, - TimeSpan retryDelay, - string message, - int retryCount = -1, - IScheduler scheduler = null) - { - var sourceLogged = - source - .Do( - _ => { }, - ex => logger?.LogError(ex, message)); + return RetryAfterDelay(sourceLogged, retryDelay, retryCount, scheduler); + } - return RetryAfterDelay(sourceLogged, retryDelay, retryCount, scheduler); - } + public static IObservable RepeatAfterDelay( + this IObservable source, + TimeSpan retryDelay, + int repeatCount = -1, + IScheduler scheduler = null) + { + return RedoAfterDelay(source, retryDelay, repeatCount, scheduler, Observable.Repeat, Observable.Repeat); + } - private static IObservable RedoAfterDelay(IObservable source, TimeSpan retryDelay, int retryCount, IScheduler scheduler, Func, IObservable> reDo, - Func, int, IObservable> reDoCount) - { - scheduler = scheduler ?? TaskPoolScheduler.Default; - var attempt = 0; + public static IObservable RetryAfterDelay( + this IObservable source, + TimeSpan retryDelay, + int retryCount = -1, + IScheduler scheduler = null) + { + return RedoAfterDelay(source, retryDelay, retryCount, scheduler, Observable.Retry, Observable.Retry); + } - var deferedObs = Observable.Defer(() => ((++attempt == 1) ? source : source.DelaySubscription(retryDelay, scheduler))); - return retryCount > 0 ? reDoCount(deferedObs, retryCount) : reDo(deferedObs); - } + private static IObservable RedoAfterDelay(IObservable source, TimeSpan retryDelay, int retryCount, IScheduler scheduler, Func, IObservable> reDo, + Func, int, IObservable> reDoCount) + { + scheduler = scheduler ?? TaskPoolScheduler.Default; + var attempt = 0; - public static IObservable DisposeMany(this IObservable source) - { - return Observable.Create(obs => - { - var serialDisposable = new SerialDisposable(); - var subscription = - source.Subscribe( - item => - { - serialDisposable.Disposable = item as IDisposable; - obs.OnNext(item); - }, - obs.OnError, - obs.OnCompleted); - return new CompositeDisposable(serialDisposable, subscription); - }); - } - } -} \ No newline at end of file + var deferedObs = Observable.Defer(() => ((++attempt == 1) ? source : source.DelaySubscription(retryDelay, scheduler))); + return retryCount > 0 ? reDoCount(deferedObs, retryCount) : reDo(deferedObs); + } +} diff --git a/Sharp7.Rx/Extensions/OperandExtensions.cs b/Sharp7.Rx/Extensions/OperandExtensions.cs new file mode 100644 index 0000000..b2c6744 --- /dev/null +++ b/Sharp7.Rx/Extensions/OperandExtensions.cs @@ -0,0 +1,16 @@ +using Sharp7.Rx.Enums; + +namespace Sharp7.Rx.Extensions; + +internal static class OperandExtensions +{ + public static S7Area ToArea(this Operand operand) => + operand switch + { + Operand.Input => S7Area.PE, + Operand.Output => S7Area.PA, + Operand.Marker => S7Area.MK, + Operand.Db => S7Area.DB, + _ => throw new ArgumentOutOfRangeException(nameof(operand), operand, null) + }; +} diff --git a/Sharp7.Rx/Extensions/PlcExtensions.cs b/Sharp7.Rx/Extensions/PlcExtensions.cs index 619dd24..af2738d 100644 --- a/Sharp7.Rx/Extensions/PlcExtensions.cs +++ b/Sharp7.Rx/Extensions/PlcExtensions.cs @@ -1,74 +1,72 @@ -using System; -using System.Reactive; +using System.Reactive; using System.Reactive.Disposables; using System.Reactive.Linq; using System.Reactive.Threading.Tasks; -using System.Threading.Tasks; using Sharp7.Rx.Enums; using Sharp7.Rx.Interfaces; -namespace Sharp7.Rx.Extensions +namespace Sharp7.Rx.Extensions; + +public static class PlcExtensions { - public static class PlcExtensions + public static IObservable CreateDatatransferWithHandshake(this IPlc plc, string triggerAddress, string ackTriggerAddress, Func> readData, + bool initialTransfer) { - public static IObservable CreateDatatransferWithHandshake(this IPlc plc, string triggerAddress, string ackTriggerAddress, Func> readData, bool initialTransfer) + return Observable.Create(async observer => { - return Observable.Create(async observer => + var subscriptions = new CompositeDisposable(); + + var notification = plc + .CreateNotification(triggerAddress, TransmissionMode.OnChange) + .Publish() + .RefCount(); + + if (initialTransfer) { - var subscriptions = new CompositeDisposable(); + await plc.ConnectionState.FirstAsync(state => state == ConnectionState.Connected).ToTask(); + var initialValue = await ReadData(plc, readData); + observer.OnNext(initialValue); + } - var notification = plc - .CreateNotification(triggerAddress, TransmissionMode.OnChange, TimeSpan.Zero) - .Publish() - .RefCount(); + notification + .Where(trigger => trigger) + .SelectMany(_ => ReadDataAndAcknowlodge(plc, readData, ackTriggerAddress)) + .Subscribe(observer) + .AddDisposableTo(subscriptions); - if (initialTransfer) + notification + .Where(trigger => !trigger) + .SelectMany(async _ => { - await plc.ConnectionState.FirstAsync(state => state == ConnectionState.Connected).ToTask(); - var initialValue = await ReadData(plc, readData); - observer.OnNext(initialValue); - } + await plc.SetValue(ackTriggerAddress, false); + return Unit.Default; + }) + .Subscribe() + .AddDisposableTo(subscriptions); - notification - .Where(trigger => trigger) - .SelectMany(_ => ReadDataAndAcknowlodge(plc, readData, ackTriggerAddress)) - .Subscribe(observer) - .AddDisposableTo(subscriptions); + return subscriptions; + }); + } - notification - .Where(trigger => !trigger) - .SelectMany(async _ => - { - await plc.SetValue(ackTriggerAddress, false); - return Unit.Default; - }) - .Subscribe() - .AddDisposableTo(subscriptions); + public static IObservable CreateDatatransferWithHandshake(this IPlc plc, string triggerAddress, string ackTriggerAddress, Func> readData) + { + return CreateDatatransferWithHandshake(plc, triggerAddress, ackTriggerAddress, readData, false); + } - return subscriptions; - }); - } + private static async Task ReadData(IPlc plc, Func> receiveData) + { + return await receiveData(plc); + } - public static IObservable CreateDatatransferWithHandshake(this IPlc plc, string triggerAddress, string ackTriggerAddress, Func> readData) + private static async Task ReadDataAndAcknowlodge(IPlc plc, Func> readData, string ackTriggerAddress) + { + try { - return CreateDatatransferWithHandshake(plc, triggerAddress, ackTriggerAddress, readData, false); + return await ReadData(plc, readData); } - - private static async Task ReadData(IPlc plc, Func> receiveData) + finally { - return await receiveData(plc); - } - - private static async Task ReadDataAndAcknowlodge(IPlc plc, Func> readData, string ackTriggerAddress) - { - try - { - return await ReadData(plc, readData); - } - finally - { - await plc.SetValue(ackTriggerAddress, true); - } + await plc.SetValue(ackTriggerAddress, true); } } } diff --git a/Sharp7.Rx/Extensions/S7VariableExtensions.cs b/Sharp7.Rx/Extensions/S7VariableExtensions.cs new file mode 100644 index 0000000..f125bb6 --- /dev/null +++ b/Sharp7.Rx/Extensions/S7VariableExtensions.cs @@ -0,0 +1,44 @@ +using Sharp7.Rx.Enums; + +namespace Sharp7.Rx.Extensions; + +internal static class VariableAddressExtensions +{ + private static readonly Dictionary> supportedTypeMap = new() + { + {typeof(bool), a => a.Type == DbType.Bit}, + {typeof(string), a => a.Type is DbType.String or DbType.WString or DbType.Byte}, + {typeof(byte), a => a.Type == DbType.Byte && a.Length == 1}, + {typeof(short), a => a.Type == DbType.Int}, + {typeof(ushort), a => a.Type == DbType.UInt}, + {typeof(int), a => a.Type == DbType.DInt}, + {typeof(uint), a => a.Type == DbType.UDInt}, + {typeof(long), a => a.Type == DbType.LInt}, + {typeof(ulong), a => a.Type == DbType.ULInt}, + {typeof(float), a => a.Type == DbType.Single}, + {typeof(double), a => a.Type == DbType.Double}, + {typeof(byte[]), a => a.Type == DbType.Byte}, + }; + + public static bool MatchesType(this VariableAddress address, Type type) => + supportedTypeMap.TryGetValue(type, out var map) && map(address); + + public static Type GetClrType(this VariableAddress address) => + address.Type switch + { + DbType.Bit => typeof(bool), + DbType.String => typeof(string), + DbType.WString => typeof(string), + DbType.Byte => address.Length == 1 ? typeof(byte) : typeof(byte[]), + DbType.Int => typeof(short), + DbType.UInt => typeof(ushort), + DbType.DInt => typeof(int), + DbType.UDInt => typeof(uint), + DbType.LInt => typeof(long), + DbType.ULInt => typeof(ulong), + DbType.Single => typeof(float), + DbType.Double => typeof(double), + _ => throw new ArgumentOutOfRangeException(nameof(address)) + }; + +} diff --git a/Sharp7.Rx/Interfaces/IPlc.cs b/Sharp7.Rx/Interfaces/IPlc.cs index e5c4c89..b2c4aca 100644 --- a/Sharp7.Rx/Interfaces/IPlc.cs +++ b/Sharp7.Rx/Interfaces/IPlc.cs @@ -1,16 +1,18 @@ -using System; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; +using JetBrains.Annotations; using Sharp7.Rx.Enums; -namespace Sharp7.Rx.Interfaces +namespace Sharp7.Rx.Interfaces; + +[NoReorder] +public interface IPlc : IDisposable { - public interface IPlc : IDisposable - { - IObservable CreateNotification(string variableName, TransmissionMode transmissionMode, TimeSpan cycleSpan); - Task SetValue(string variableName, TValue value); - Task GetValue(string variableName); - IObservable ConnectionState { get; } - ILogger Logger { get; } - } + IObservable ConnectionState { get; } + + Task SetValue(string variableName, TValue value, CancellationToken token = default); + + Task GetValue(string variableName, CancellationToken token = default); + Task GetValue(string variableName, CancellationToken token = default); + + IObservable CreateNotification(string variableName, TransmissionMode transmissionMode); + IObservable CreateNotification(string variableName, TransmissionMode transmissionMode); } diff --git a/Sharp7.Rx/Interfaces/IS7Connector.cs b/Sharp7.Rx/Interfaces/IS7Connector.cs deleted file mode 100644 index 74767a0..0000000 --- a/Sharp7.Rx/Interfaces/IS7Connector.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using Sharp7.Rx.Enums; - -namespace Sharp7.Rx.Interfaces -{ - internal interface IS7Connector : IDisposable - { - IObservable ConnectionState { get; } - Task InitializeAsync(); - - Task Connect(); - Task Disconnect(); - - Task ReadBit(Operand operand, ushort byteAddress, byte bitAdress, ushort dbNr, CancellationToken token); - Task ReadBytes(Operand operand, ushort startByteAddress, ushort bytesToRead, ushort dBNr, CancellationToken token); - - Task WriteBit(Operand operand, ushort startByteAddress, byte bitAdress, bool value, ushort dbNr, CancellationToken token); - Task WriteBytes(Operand operand, ushort startByteAdress, byte[] data, ushort dBNr, CancellationToken token); - ILogger Logger { get; } - Task> ExecuteMultiVarRequest(IEnumerable variableNames); - } -} \ No newline at end of file diff --git a/Sharp7.Rx/Interfaces/IS7VariableNameParser.cs b/Sharp7.Rx/Interfaces/IS7VariableNameParser.cs deleted file mode 100644 index dd8e272..0000000 --- a/Sharp7.Rx/Interfaces/IS7VariableNameParser.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Sharp7.Rx.Interfaces -{ - internal interface IS7VariableNameParser - { - S7VariableAddress Parse(string input); - } -} \ No newline at end of file diff --git a/Sharp7.Rx/Interfaces/IVariableNameParser.cs b/Sharp7.Rx/Interfaces/IVariableNameParser.cs new file mode 100644 index 0000000..d6f8219 --- /dev/null +++ b/Sharp7.Rx/Interfaces/IVariableNameParser.cs @@ -0,0 +1,7 @@ +#nullable enable +namespace Sharp7.Rx.Interfaces; + +internal interface IVariableNameParser +{ + VariableAddress Parse(string input); +} diff --git a/Sharp7.Rx/Resources/StringResources.Designer.cs b/Sharp7.Rx/Resources/StringResources.Designer.cs deleted file mode 100644 index f1d725d..0000000 --- a/Sharp7.Rx/Resources/StringResources.Designer.cs +++ /dev/null @@ -1,117 +0,0 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// Runtime Version:4.0.30319.42000 -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - -namespace Sharp7.Rx.Resources { - using System; - - - /// - /// A strongly-typed resource class, for looking up localized strings, etc. - /// - // This class was auto-generated by the StronglyTypedResourceBuilder - // class via a tool like ResGen or Visual Studio. - // To add or remove a member, edit your .ResX file then rerun ResGen - // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "15.0.0.0")] - [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] - [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - internal class StringResources { - - private static global::System.Resources.ResourceManager resourceMan; - - private static global::System.Globalization.CultureInfo resourceCulture; - - [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] - internal StringResources() { - } - - /// - /// Returns the cached ResourceManager instance used by this class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - internal static global::System.Resources.ResourceManager ResourceManager { - get { - if (object.ReferenceEquals(resourceMan, null)) { - global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Sharp7.Rx.Resources.StringResources", typeof(StringResources).Assembly); - resourceMan = temp; - } - return resourceMan; - } - } - - /// - /// Overrides the current thread's CurrentUICulture property for all - /// resource lookups using this strongly typed resource class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - internal static global::System.Globalization.CultureInfo Culture { - get { - return resourceCulture; - } - set { - resourceCulture = value; - } - } - - /// - /// Looks up a localized string similar to S7 driver could not be initialized. - /// - internal static string StrErrorS7DriverCouldNotBeInitialized { - get { - return ResourceManager.GetString("StrErrorS7DriverCouldNotBeInitialized", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to S7 driver is not initialized.. - /// - internal static string StrErrorS7DriverNotInitialized { - get { - return ResourceManager.GetString("StrErrorS7DriverNotInitialized", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to TCP/IP connection established.. - /// - internal static string StrInfoConnectionEstablished { - get { - return ResourceManager.GetString("StrInfoConnectionEstablished", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Trying to connect to PLC ({2}) '{0}', CPU slot {1}.... - /// - internal static string StrInfoTryConnecting { - get { - return ResourceManager.GetString("StrInfoTryConnecting", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Error while reading data from plc.. - /// - internal static string StrLogErrorReadingDataFromPlc { - get { - return ResourceManager.GetString("StrLogErrorReadingDataFromPlc", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Communication error discovered. Reconnect is in progress.... - /// - internal static string StrLogWarningCommunictionErrorReconnecting { - get { - return ResourceManager.GetString("StrLogWarningCommunictionErrorReconnecting", resourceCulture); - } - } - } -} diff --git a/Sharp7.Rx/Resources/StringResources.resx b/Sharp7.Rx/Resources/StringResources.resx deleted file mode 100644 index 3eff273..0000000 --- a/Sharp7.Rx/Resources/StringResources.resx +++ /dev/null @@ -1,138 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - Error while reading data from plc. - - - Communication error discovered. Reconnect is in progress... - - - S7 driver is not initialized. - - - Trying to connect to PLC ({2}) '{0}', CPU slot {1}... - - - TCP/IP connection established. - - - S7 driver could not be initialized - - \ No newline at end of file diff --git a/Sharp7.Rx/S7ErrorCodes.cs b/Sharp7.Rx/S7ErrorCodes.cs new file mode 100644 index 0000000..7734149 --- /dev/null +++ b/Sharp7.Rx/S7ErrorCodes.cs @@ -0,0 +1,41 @@ +#nullable enable + +using System.Diagnostics.CodeAnalysis; + +namespace Sharp7.Rx; + +public static class S7ErrorCodes +{ + /// + /// This list is not exhaustive and should be considered work in progress. + /// + private static readonly HashSet notDisconnectedErrorCodes = + [ + 0x000000, // OK + 0xC00000, // CPU: Item not available + 0x900000 // CPU: Address out of range + ]; + + private static readonly IReadOnlyDictionary additionalErrorTexts = new Dictionary + { + {0xC00000, "This happens when the DB does not exist."}, + {0x900000, "This happens when the DB is not long enough."}, + { + 0x40000, """ + This error occurs when the DB is "optimized" or "PUT/GET communication" is not enabled. + See https://snap7.sourceforge.net/snap7_client.html#target_compatibility. + """ + } + }; + + /// + /// Some error codes indicate connection lost, in which case, the driver tries to reestablish connection. + /// Other error codes indicate a user error, like reading from an unavailable DB or exceeding + /// the DBs range. In this case the driver should not consider the connection to be lost. + /// + public static bool AssumeConnectionLost(int errorCode) => + !notDisconnectedErrorCodes.Contains(errorCode); + + public static string? GetAdditionalErrorText(int errorCode) => + additionalErrorTexts.GetValueOrDefault(errorCode); +} diff --git a/Sharp7.Rx/S7VariableAddress.cs b/Sharp7.Rx/S7VariableAddress.cs deleted file mode 100644 index ef2fb5a..0000000 --- a/Sharp7.Rx/S7VariableAddress.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Sharp7.Rx.Enums; - -namespace Sharp7.Rx -{ - internal class S7VariableAddress - { - public Operand Operand { get; set; } - public ushort DbNr { get; set; } - public ushort Start { get; set; } - public ushort Length { get; set; } - public byte Bit { get; set; } - public DbType Type { get; set; } - } -} \ No newline at end of file diff --git a/Sharp7.Rx/S7VariableNameParser.cs b/Sharp7.Rx/S7VariableNameParser.cs deleted file mode 100644 index a53e8ef..0000000 --- a/Sharp7.Rx/S7VariableNameParser.cs +++ /dev/null @@ -1,96 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Text.RegularExpressions; -using Sharp7.Rx.Enums; -using Sharp7.Rx.Interfaces; - -namespace Sharp7.Rx -{ - internal class S7VariableNameParser : IS7VariableNameParser - { - private readonly Regex regex = new Regex(@"^(?db{1})(?\d{1,4})\.?(?dbx|x|s|string|b|dbb|d|int|dbw|w|dint|dul|dulint|dulong|){1}(?\d+)(\.(?\d+))?$", RegexOptions.IgnoreCase); - - private readonly Dictionary types = new Dictionary - { - {"x", DbType.Bit}, - {"dbx", DbType.Bit}, - {"s", DbType.String}, - {"string", DbType.String}, - {"b", DbType.Byte}, - {"dbb", DbType.Byte}, - {"d", DbType.Double}, - {"int", DbType.Integer}, - {"dint", DbType.DInteger}, - {"w", DbType.Integer}, - {"dbw", DbType.Integer}, - {"dul", DbType.ULong }, - {"dulint", DbType.ULong }, - {"dulong", DbType.ULong } - }; - - - public S7VariableAddress Parse(string input) - { - var match = regex.Match(input); - if (match.Success) - { - var operand = (Operand)Enum.Parse(typeof(Operand), match.Groups["operand"].Value, true); - var dbNr = ushort.Parse(match.Groups["dbNr"].Value, NumberStyles.Integer); - var start = ushort.Parse(match.Groups["start"].Value, NumberStyles.Integer); - var type = ParseType(match.Groups["type"].Value); - - var s7VariableAddress = new S7VariableAddress - { - Operand = operand, - DbNr = dbNr, - Start = start, - Type = type, - }; - - if (type == DbType.Bit) - { - s7VariableAddress.Length = 1; - s7VariableAddress.Bit = byte.Parse(match.Groups["bitOrLength"].Value); - } - else if (type == DbType.Byte) - { - s7VariableAddress.Length = match.Groups["bitOrLength"].Success ? ushort.Parse(match.Groups["bitOrLength"].Value) : (ushort)1; - } - else if (type == DbType.String) - { - s7VariableAddress.Length = match.Groups["bitOrLength"].Success ? ushort.Parse(match.Groups["bitOrLength"].Value) : (ushort)0; - } - else if (type == DbType.Integer) - { - s7VariableAddress.Length = 2; - } - else if (type == DbType.DInteger) - { - s7VariableAddress.Length = 4; - } - else if (type == DbType.ULong) - { - s7VariableAddress.Length = 8; - } - else if (type == DbType.Double) - { - s7VariableAddress.Length = 4; - } - - return s7VariableAddress; - } - - return null; - } - - private DbType ParseType(string value) - { - return types - .Where(pair => pair.Key.Equals(value, StringComparison.InvariantCultureIgnoreCase)) - .Select(pair => pair.Value) - .FirstOrDefault(); - } - } -} \ No newline at end of file diff --git a/Sharp7.Rx/Settings/PlcConnectionSettings.cs b/Sharp7.Rx/Settings/PlcConnectionSettings.cs index b4b5f85..6ec2e0b 100644 --- a/Sharp7.Rx/Settings/PlcConnectionSettings.cs +++ b/Sharp7.Rx/Settings/PlcConnectionSettings.cs @@ -1,10 +1,9 @@ -namespace Sharp7.Rx.Settings +namespace Sharp7.Rx.Settings; + +internal class PlcConnectionSettings { - internal class PlcConnectionSettings - { - public string IpAddress { get; set; } - public int RackNumber { get; set; } - public int CpuMpiAddress { get; set; } - public int Port { get; set; } - } -} \ No newline at end of file + public int CpuMpiAddress { get; set; } + public string IpAddress { get; set; } + public int Port { get; set; } + public int RackNumber { get; set; } +} diff --git a/Sharp7.Rx/Sharp7.Rx.csproj b/Sharp7.Rx/Sharp7.Rx.csproj index 853ccf0..3067b4e 100644 --- a/Sharp7.Rx/Sharp7.Rx.csproj +++ b/Sharp7.Rx/Sharp7.Rx.csproj @@ -1,38 +1,48 @@  - netstandard2.0 + net6.0 + + 12.0 + disable + enable + latest-Recommended + true $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb evopro system engineering AG evopro system engineering AG - Reactive framework for Sharp7, the Ethernet S7 PLC communication suite + Reactive framework for Sharp7, the Ethernet S7 PLC communication suite. Handling RFC1006 connections to Siemens S7 300, 1200 and 1500. + linqpad-samples https://github.com/evopro-ag/Sharp7Reactive - https://raw.githubusercontent.com/evopro-ag/Sharp7Reactive/master/LICENSE + Apache-2.0 true snupkg + + + $(NoWarn);CA1848;CA2254;CA1859 - - - - + + + + + - - True - True - StringResources.resx - + - - ResXFileCodeGenerator - StringResources.Designer.cs - + + true + linqpad-samples\;content + - diff --git a/Sharp7.Rx/Sharp7.Rx.csproj.DotSettings b/Sharp7.Rx/Sharp7.Rx.csproj.DotSettings new file mode 100644 index 0000000..374f4af --- /dev/null +++ b/Sharp7.Rx/Sharp7.Rx.csproj.DotSettings @@ -0,0 +1,2 @@ + + True \ No newline at end of file diff --git a/Sharp7.Rx/Sharp7Connector.cs b/Sharp7.Rx/Sharp7Connector.cs index 89e7921..440fb09 100644 --- a/Sharp7.Rx/Sharp7Connector.cs +++ b/Sharp7.Rx/Sharp7Connector.cs @@ -1,312 +1,274 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Reactive.Disposables; +using System.Reactive.Disposables; using System.Reactive.Linq; using System.Reactive.Subjects; -using System.Threading; -using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Sharp7.Rx.Basics; using Sharp7.Rx.Enums; using Sharp7.Rx.Extensions; using Sharp7.Rx.Interfaces; -using Sharp7.Rx.Resources; using Sharp7.Rx.Settings; -namespace Sharp7.Rx +namespace Sharp7.Rx; + +internal class Sharp7Connector: IDisposable { - internal class Sharp7Connector : IS7Connector + private readonly BehaviorSubject connectionStateSubject = new(Enums.ConnectionState.Initial); + private readonly int cpuSlotNr; + + private readonly CompositeDisposable disposables = new(); + private readonly string ipAddress; + private readonly int port; + private readonly int rackNr; + private readonly LimitedConcurrencyLevelTaskScheduler scheduler = new(maxDegreeOfParallelism: 1); + private readonly IVariableNameParser variableNameParser; + private bool disposed; + + private S7Client sharp7; + + + public Sharp7Connector(PlcConnectionSettings settings, IVariableNameParser variableNameParser) { - private readonly IS7VariableNameParser variableNameParser; - private readonly BehaviorSubject connectionStateSubject = new BehaviorSubject(Enums.ConnectionState.Initial); - private ConcurrentDictionary s7VariableAddresses = new ConcurrentDictionary(); + this.variableNameParser = variableNameParser; + ipAddress = settings.IpAddress; + cpuSlotNr = settings.CpuMpiAddress; + port = settings.Port; + rackNr = settings.RackNumber; - private readonly CompositeDisposable disposables = new CompositeDisposable(); - private readonly LimitedConcurrencyLevelTaskScheduler scheduler = new LimitedConcurrencyLevelTaskScheduler(maxDegreeOfParallelism:1); - private readonly string ipAddress; - private readonly int rackNr; - private readonly int cpuSlotNr; - private readonly int port; + ReconnectDelay = TimeSpan.FromSeconds(5); + } - private S7Client sharp7; - private bool disposed; + public IObservable ConnectionState => connectionStateSubject.DistinctUntilChanged().AsObservable(); - public ILogger Logger { get; set; } - public async Task> ExecuteMultiVarRequest(IEnumerable variableNames) - { - var enumerable = variableNames as string[] ?? variableNames.ToArray(); - - if (enumerable.IsEmpty()) - return new Dictionary(); - - var s7MultiVar = new S7MultiVar(sharp7); - - var buffers = enumerable.Select(key => new {VariableName = key, Address = s7VariableAddresses.GetOrAdd(key, s => variableNameParser.Parse(s))}) - .Select(x => - { - var buffer = new byte[x.Address.Length]; - s7MultiVar.Add(S7Consts.S7AreaDB, S7Consts.S7WLByte, x.Address.DbNr, x.Address.Start,x.Address.Length, ref buffer); - return new { x.VariableName, Buffer = buffer}; - }) - .ToArray(); - - var result = await Task.Factory.StartNew(() => s7MultiVar.Read(), CancellationToken.None, TaskCreationOptions.None, scheduler); - if (result != 0) - { - await EvaluateErrorCode(result); - throw new InvalidOperationException($"Error in MultiVar request for variables: {string.Join(",", enumerable)}"); - } - - return buffers.ToDictionary(arg => arg.VariableName, arg => arg.Buffer); - } - - - - public Sharp7Connector(PlcConnectionSettings settings, IS7VariableNameParser variableNameParser) - { - this.variableNameParser = variableNameParser; - this.ipAddress = settings.IpAddress; - this.cpuSlotNr = settings.CpuMpiAddress; - this.port = settings.Port; - this.rackNr = settings.RackNumber; - - ReconnectDelay = TimeSpan.FromSeconds(5); - } - - public TimeSpan ReconnectDelay { get; set; } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - public async Task Connect() - { - if (sharp7 == null) - throw new InvalidOperationException(StringResources.StrErrorS7DriverNotInitialized); - - try - { - var errorCode = await Task.Factory.StartNew(() => sharp7.ConnectTo(ipAddress, rackNr, cpuSlotNr), CancellationToken.None, TaskCreationOptions.None, scheduler); - var success = await EvaluateErrorCode(errorCode); - if (success) - { - connectionStateSubject.OnNext(Enums.ConnectionState.Connected); - return true; - } - } - catch (Exception ex) - { - // TODO: - } - - return false; - } - - public IObservable ConnectionState => connectionStateSubject.DistinctUntilChanged().AsObservable(); - - - public async Task Disconnect() - { - connectionStateSubject.OnNext(Enums.ConnectionState.DisconnectedByUser); - await CloseConnection(); - } - - public Task InitializeAsync() - { - try - { - sharp7 = new S7Client(); - sharp7.PLCPort = this.port; - - var subscription = - ConnectionState - .Where(state => state == Enums.ConnectionState.ConnectionLost) - .Take(1) - .SelectMany(_ => Reconnect()) - .RepeatAfterDelay(ReconnectDelay) - .LogAndRetry(Logger, "Error while reconnecting to S7.") - .Subscribe(); - - disposables.Add(subscription); - } - catch (Exception ex) - { - Logger?.LogError(ex, StringResources.StrErrorS7DriverCouldNotBeInitialized); - } - - return Task.FromResult(true); - } - - - protected virtual void Dispose(bool disposing) - { - if (!disposed) - { - if (disposing) - { - disposables.Dispose(); - - if (sharp7 != null) - { - sharp7.Disconnect(); - sharp7 = null; - } - - connectionStateSubject?.Dispose(); - } - - disposed = true; - } - } - - private async Task CloseConnection() - { - if (sharp7 == null) - throw new InvalidOperationException(StringResources.StrErrorS7DriverNotInitialized); - - await Task.Factory.StartNew(() => sharp7.Disconnect(), CancellationToken.None, TaskCreationOptions.None, scheduler); - } - - private async Task EvaluateErrorCode(int errorCode) + public ILogger Logger { get; set; } + + public TimeSpan ReconnectDelay { get; set; } + + private bool IsConnected => connectionStateSubject.Value == Enums.ConnectionState.Connected; + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + public async Task Connect() + { + if (sharp7 == null) + throw new InvalidOperationException("S7 driver is not initialized."); + + try { + var errorCode = await Task.Factory.StartNew(() => sharp7.ConnectTo(ipAddress, rackNr, cpuSlotNr), CancellationToken.None, TaskCreationOptions.None, scheduler); if (errorCode == 0) + { + connectionStateSubject.OnNext(Enums.ConnectionState.Connected); return true; - - if (sharp7 == null) - throw new InvalidOperationException(StringResources.StrErrorS7DriverNotInitialized); - - var errorText = sharp7.ErrorText(errorCode); - Logger?.LogError($"Error Code {errorCode} {errorText}"); - await SetConnectionLostState(); - - return false; + } + else + { + var errorText = EvaluateErrorCode(errorCode); + Logger.LogError("Failed to establish initial connection: {Error}", errorText); + } } - - private async Task Reconnect() + catch (Exception ex) { - await CloseConnection(); - - return await Connect(); - } - - private async Task SetConnectionLostState() - { - var state = await connectionStateSubject.FirstAsync(); - if (state == Enums.ConnectionState.ConnectionLost) return; - connectionStateSubject.OnNext(Enums.ConnectionState.ConnectionLost); + Logger.LogError(ex, "Failed to establish initial connection."); } - ~Sharp7Connector() - { - Dispose(false); - } + return false; + } - private bool IsConnected => connectionStateSubject.Value == Enums.ConnectionState.Connected; - public async Task ReadBytes(Operand operand, ushort startByteAddress, ushort bytesToRead, ushort dBNr, CancellationToken token) - { - EnsureConnectionValid(); + public async Task Disconnect() + { + connectionStateSubject.OnNext(Enums.ConnectionState.DisconnectedByUser); + await CloseConnection(); + } - var buffer = new byte[bytesToRead]; + public async Task> ExecuteMultiVarRequest(IReadOnlyList variableNames) + { + if (variableNames.IsEmpty()) + return new Dictionary(); - var area = FromOperand(operand); + var s7MultiVar = new S7MultiVar(sharp7); - var result = - await Task.Factory.StartNew(() => sharp7.ReadArea(area, dBNr, startByteAddress, bytesToRead, S7Consts.S7WLByte, buffer), token, TaskCreationOptions.None, scheduler); - token.ThrowIfCancellationRequested(); - - if (result != 0) + var buffers = variableNames + .Select(key => new {VariableName = key, Address = variableNameParser.Parse(key)}) + .Select(x => { - await EvaluateErrorCode(result); - var errorText = this.sharp7.ErrorText(result); - throw new InvalidOperationException($"Error reading {operand}{dBNr}:{startByteAddress}->{bytesToRead} ({errorText})"); + var buffer = new byte[x.Address.BufferLength]; +#pragma warning disable CS0618 // Type or member is obsolete, no matching overload. + s7MultiVar.Add(S7Consts.S7AreaDB, S7Consts.S7WLByte, x.Address.DbNo, x.Address.Start, x.Address.BufferLength, ref buffer); +#pragma warning restore CS0618 + return new {x.VariableName, Buffer = buffer}; + }) + .ToArray(); + + var result = await Task.Factory.StartNew(() => s7MultiVar.Read(), CancellationToken.None, TaskCreationOptions.None, scheduler); + + EnsureSuccessOrThrow(result, $"Error in MultiVar request for variables: {string.Join(",", variableNames)}"); + + return buffers.ToDictionary(arg => arg.VariableName, arg => arg.Buffer); + } + + public Task InitializeAsync() + { + try + { + sharp7 = new S7Client(); + sharp7.PLCPort = port; + + var subscription = + ConnectionState + .Where(state => state == Enums.ConnectionState.ConnectionLost) + .Take(1) + .SelectMany(_ => Reconnect()) + .RepeatAfterDelay(ReconnectDelay) + .LogAndRetry(Logger, "Error while reconnecting to S7.") + .Subscribe(); + + disposables.Add(subscription); + } + catch (Exception ex) + { + Logger?.LogError(ex, "S7 driver could not be initialized"); + } + + return Task.FromResult(true); + } + + public async Task ReadBytes(Operand operand, ushort startByteAddress, ushort bytesToRead, ushort dbNo, CancellationToken token) + { + EnsureConnectionValid(); + + var buffer = new byte[bytesToRead]; + + + var result = + await Task.Factory.StartNew(() => sharp7.ReadArea(operand.ToArea(), dbNo, startByteAddress, bytesToRead, S7WordLength.Byte, buffer), token, TaskCreationOptions.None, scheduler); + token.ThrowIfCancellationRequested(); + + EnsureSuccessOrThrow(result, $"Error reading {operand}{dbNo}:{startByteAddress}->{bytesToRead}"); + + return buffer; + } + + public async Task WriteBit(Operand operand, ushort startByteAddress, byte bitAdress, bool value, ushort dbNo, CancellationToken token) + { + EnsureConnectionValid(); + + var buffer = new[] {value ? (byte) 0xff : (byte) 0}; + + var offsetStart = (startByteAddress * 8) + bitAdress; + + var result = await Task.Factory.StartNew(() => sharp7.WriteArea(operand.ToArea(), dbNo, offsetStart, 1, S7WordLength.Bit, buffer), token, TaskCreationOptions.None, scheduler); + token.ThrowIfCancellationRequested(); + + EnsureSuccessOrThrow(result, $"Error writing {operand}{dbNo}:{startByteAddress} bit {bitAdress}"); + } + + public async Task WriteBytes(Operand operand, ushort startByteAddress, byte[] data, ushort dbNo, ushort bytesToWrite, CancellationToken token) + { + EnsureConnectionValid(); + + var result = await Task.Factory.StartNew(() => sharp7.WriteArea(operand.ToArea(), dbNo, startByteAddress, bytesToWrite, S7WordLength.Byte, data), token, TaskCreationOptions.None, scheduler); + token.ThrowIfCancellationRequested(); + + EnsureSuccessOrThrow(result, $"Error writing {operand}{dbNo}:{startByteAddress}.{data.Length}"); + } + + + protected virtual void Dispose(bool disposing) + { + if (!disposed) + { + if (disposing) + { + disposables.Dispose(); + + if (sharp7 != null) + { + sharp7.Disconnect(); + sharp7 = null; + } + + connectionStateSubject?.OnNext(Enums.ConnectionState.Disposed); + connectionStateSubject?.OnCompleted(); + connectionStateSubject?.Dispose(); } - var retBuffer = new byte[bytesToRead]; - Array.Copy(buffer, 0, retBuffer, 0, bytesToRead); - return (retBuffer); - } - - private int FromOperand(Operand operand) - { - switch (operand) - { - case Operand.Input: - return S7Consts.S7AreaPE; - case Operand.Output: - return S7Consts.S7AreaPA; - case Operand.Marker: - return S7Consts.S7AreaMK; - case Operand.Db: - return S7Consts.S7AreaDB; - default: - throw new ArgumentOutOfRangeException(nameof(operand), operand, null); - } - } - - private void EnsureConnectionValid() - { - if (disposed) - throw new ObjectDisposedException("S7Connector"); - - if (sharp7 == null) - throw new InvalidOperationException(StringResources.StrErrorS7DriverNotInitialized); - - if (!IsConnected) - throw new InvalidOperationException("Plc is not connected"); - } - - public async Task WriteBytes(Operand operand, ushort startByteAdress, byte[] data, ushort dBNr, CancellationToken token) - { - EnsureConnectionValid(); - - var result = await Task.Factory.StartNew(() => sharp7.WriteArea(FromOperand(operand), dBNr, startByteAdress, data.Length, S7Consts.S7WLByte, data), token, TaskCreationOptions.None, scheduler); - token.ThrowIfCancellationRequested(); - - if (result != 0) - { - await EvaluateErrorCode(result); - return (0); - } - return (ushort)(data.Length); - } - - - public async Task ReadBit(Operand operand, ushort byteAddress, byte bitAdress, ushort dbNr, CancellationToken token) - { - EnsureConnectionValid(); - - var byteValue = await ReadBytes(operand, byteAddress, 1, dbNr, token); - token.ThrowIfCancellationRequested(); - - if (byteValue.Length != 1) - throw new InvalidOperationException("Read bytes does not have length 1"); - - return Convert.ToBoolean(byteValue[0] & (1 << bitAdress)); - } - - public async Task WriteBit(Operand operand, ushort startByteAddress, byte bitAdress, bool value, ushort dbNr, CancellationToken token) - { - EnsureConnectionValid(); - - var buffer = new byte[] { value ? (byte)0xff : (byte)0 }; - - var offsetStart = (startByteAddress * 8) + bitAdress; - - var result = await Task.Factory.StartNew(() => sharp7.WriteArea(FromOperand(operand), dbNr, offsetStart, 1, S7Consts.S7WLBit, buffer), token, TaskCreationOptions.None, scheduler); - token.ThrowIfCancellationRequested(); - - if (result != 0) - { - await EvaluateErrorCode(result); - return (false); - } - return (true); + disposed = true; } } -} \ No newline at end of file + + private async Task CloseConnection() + { + if (sharp7 == null) + throw new InvalidOperationException("S7 driver is not initialized."); + + await Task.Factory.StartNew(() => sharp7.Disconnect(), CancellationToken.None, TaskCreationOptions.None, scheduler); + } + + private void EnsureConnectionValid() + { + if (disposed) + throw new ObjectDisposedException(nameof(Sharp7Connector)); + + if (sharp7 == null) + throw new InvalidOperationException("S7 driver is not initialized."); + + if (!IsConnected) + throw new InvalidOperationException("Plc is not connected"); + } + + private void EnsureSuccessOrThrow(int result, string message) + { + if (result == 0) return; + + var errorText = EvaluateErrorCode(result); + var completeMessage = $"{message}: {errorText}"; + + var additionalErrorText = S7ErrorCodes.GetAdditionalErrorText(result); + if (additionalErrorText != null) + completeMessage += Environment.NewLine + additionalErrorText; + + throw new S7CommunicationException(completeMessage, result, errorText); + } + + private string EvaluateErrorCode(int errorCode) + { + if (errorCode == 0) + return null; + + if (sharp7 == null) + throw new InvalidOperationException("S7 driver is not initialized."); + + var errorText = $"0x{errorCode:X}, {sharp7.ErrorText(errorCode)}"; + Logger?.LogError($"S7 Error {errorText}"); + + if (S7ErrorCodes.AssumeConnectionLost(errorCode)) + SetConnectionLostState(); + + return errorText; + } + + private async Task Reconnect() + { + await CloseConnection(); + + return await Connect(); + } + + private void SetConnectionLostState() + { + if (connectionStateSubject.Value == Enums.ConnectionState.ConnectionLost) return; + + connectionStateSubject.OnNext(Enums.ConnectionState.ConnectionLost); + } + + ~Sharp7Connector() + { + Dispose(false); + } +} diff --git a/Sharp7.Rx/Sharp7Plc.cs b/Sharp7.Rx/Sharp7Plc.cs index 3273a40..bc98040 100644 --- a/Sharp7.Rx/Sharp7Plc.cs +++ b/Sharp7.Rx/Sharp7Plc.cs @@ -1,441 +1,370 @@ -using System; -using System.Collections.Generic; +using System.Buffers; using System.Diagnostics; -using System.Linq; using System.Reactive; using System.Reactive.Disposables; using System.Reactive.Linq; -using System.Reactive.Subjects; -using System.Text; -using System.Threading; -using System.Threading.Tasks; +using System.Reactive.Threading.Tasks; +using System.Reflection; using Microsoft.Extensions.Logging; using Sharp7.Rx.Basics; using Sharp7.Rx.Enums; using Sharp7.Rx.Extensions; using Sharp7.Rx.Interfaces; using Sharp7.Rx.Settings; +using Sharp7.Rx.Utils; -namespace Sharp7.Rx +namespace Sharp7.Rx; + +public class Sharp7Plc : IPlc { - public class Sharp7Plc : IPlc + private static readonly ArrayPool arrayPool = ArrayPool.Shared; + + private static readonly MethodInfo getValueMethod = typeof(Sharp7Plc).GetMethods() + .Single(m => m.Name == nameof(GetValue) && m.GetGenericArguments().Length == 1); + + private static readonly MethodInfo createNotificationMethod = typeof(Sharp7Plc).GetMethods() + .Single(m => m.Name == nameof(CreateNotification) && m.GetGenericArguments().Length == 1); + + private readonly ConcurrentSubjectDictionary multiVariableSubscriptions = new(StringComparer.InvariantCultureIgnoreCase); + private readonly List performanceCounter = new(1000); + private readonly PlcConnectionSettings plcConnectionSettings; + private readonly CacheVariableNameParser variableNameParser = new CacheVariableNameParser(new VariableNameParser()); + private bool disposed; + private int initialized; + + private IDisposable notificationSubscription; + private Sharp7Connector s7Connector; + + /// + /// + /// + /// + /// + /// + /// + /// + /// Polling interval for multi variable read from PLC. + /// + /// + /// This is the wait time between two successive reads from PLC and determines the + /// time resolution for all variable reads related with CreateNotification. + /// + /// + /// Default is 100 ms. The minimum supported time is 5 ms. + /// + /// + public Sharp7Plc(string ipAddress, int rackNumber, int cpuMpiAddress, int port = 102, TimeSpan? multiVarRequestCycleTime = null) { - private readonly string ipAddress; - private readonly int rackNumber; - private readonly int cpuMpiAddress; - private readonly int port; - private readonly IS7VariableNameParser varaibleNameParser; - private bool disposed; - private ISubject disposingSubject = new Subject(); - private IS7Connector s7Connector; - private readonly PlcConnectionSettings plcConnectionSettings; - private readonly ConcurrentSubjectDictionary multiVariableSubscriptions = new ConcurrentSubjectDictionary(StringComparer.InvariantCultureIgnoreCase); - protected readonly CompositeDisposable Disposables = new CompositeDisposable(); - private readonly List performanceCoutner = new List(1000); + plcConnectionSettings = new PlcConnectionSettings {IpAddress = ipAddress, RackNumber = rackNumber, CpuMpiAddress = cpuMpiAddress, Port = port}; + s7Connector = new Sharp7Connector(plcConnectionSettings, variableNameParser); + ConnectionState = s7Connector.ConnectionState; - - - public Sharp7Plc(string ipAddress, int rackNumber, int cpuMpiAddress, int port = 102) + if (multiVarRequestCycleTime != null) { - this.ipAddress = ipAddress; - this.rackNumber = rackNumber; - this.cpuMpiAddress = cpuMpiAddress; - this.port = port; - - plcConnectionSettings = new PlcConnectionSettings(){IpAddress = ipAddress, RackNumber = rackNumber, CpuMpiAddress = cpuMpiAddress, Port = port}; - - varaibleNameParser = new S7VariableNameParser(); - } - - public IObservable ConnectionState { get; private set; } - public ILogger Logger { get; set; } - - public async Task InitializeAsync() - { - s7Connector = new Sharp7Connector(plcConnectionSettings, varaibleNameParser){Logger = Logger}; - ConnectionState = s7Connector.ConnectionState; - - await s7Connector.InitializeAsync(); - -#pragma warning disable 4014 - Task.Run(async () => - { - try - { - await s7Connector.Connect(); - } - catch (Exception e) - { - Logger?.LogError(e, "Error while connecting to PLC"); - } - }); -#pragma warning restore 4014 - - RunNotifications(s7Connector, TimeSpan.FromMilliseconds(100)) - .AddDisposableTo(Disposables); - - return true; - } - - public Task GetValue(string variableName) - { - return GetValue(variableName, CancellationToken.None); - } - - private TValue ConvertToType(byte[] buffer, S7VariableAddress address) - { - if (typeof(TValue) == typeof(bool)) - { - return (TValue) (object) Convert.ToBoolean(buffer[0] & (1 << address.Bit)); - } - - if (typeof(TValue) == typeof(int)) - { - if (address.Length == 2) - return (TValue)(object)((buffer[0] << 8) + buffer[1]); - if (address.Length == 4) - { - Array.Reverse(buffer); - return (TValue)(object)BitConverter.ToInt32(buffer,0); - } - - throw new InvalidOperationException($"length must be 2 or 4 but is {address.Length}"); - } - - if (typeof(TValue) == typeof(long)) - { - Array.Reverse(buffer); - return (TValue)(object)BitConverter.ToInt64(buffer,0); - } - - if (typeof(TValue) == typeof(ulong)) - { - Array.Reverse(buffer); - return (TValue)(object)BitConverter.ToUInt64(buffer, 0); - } - - if (typeof(TValue) == typeof(short)) - { - return (TValue)(object)(short)((buffer[0] << 8) + buffer[1]); - } - - if (typeof(TValue) == typeof(byte) || typeof(TValue) == typeof(char)) - { - return (TValue)(object)buffer[0]; - } - - if (typeof(TValue) == typeof(byte[])) - { - return (TValue)(object)buffer; - } - - if (typeof(TValue) == typeof(double) || typeof(TValue) == typeof(float)) - { - var d = BitConverter.ToSingle(buffer.Reverse().ToArray(),0); - return (TValue)(object)d; - } - - if (typeof(TValue) == typeof(string)) - if (address.Type == DbType.String) - { - return (TValue) (object) Encoding.ASCII.GetString(buffer); - } - else - { - return (TValue) (object) Encoding.ASCII.GetString(buffer).Trim(); - } - - throw new InvalidOperationException(string.Format("type '{0}' not supported.", typeof(TValue))); - } - - public async Task GetValue(string variableName, CancellationToken token) - { - var address = varaibleNameParser.Parse(variableName); - if (address == null) throw new ArgumentException("Input variable name is not valid", nameof(variableName)); - - if (typeof(TValue) == typeof(bool)) - { - var b = await s7Connector.ReadBit(address.Operand, address.Start, address.Bit, address.DbNr, token); - token.ThrowIfCancellationRequested(); - return (TValue)(object)b; - } - - if (typeof(TValue) == typeof(int)) - { - var b = await s7Connector.ReadBytes(address.Operand, address.Start, address.Length, address.DbNr, token); - token.ThrowIfCancellationRequested(); - if (address.Length == 2) - return (TValue)(object)((b[0] << 8) + b[1]); - if (address.Length == 4) - { - Array.Reverse(b); - return (TValue)(object)BitConverter.ToInt32(b,0); - } - - - throw new InvalidOperationException($"length must be 2 or 4 but is {address.Length}"); - } - - if (typeof(TValue) == typeof(long)) - { - var b = await s7Connector.ReadBytes(address.Operand, address.Start, address.Length, address.DbNr, token); - token.ThrowIfCancellationRequested(); - Array.Reverse(b); - return (TValue)(object)BitConverter.ToInt64(b,0); - } - - if (typeof(TValue) == typeof(ulong)) - { - var b = await s7Connector.ReadBytes(address.Operand, address.Start, address.Length, address.DbNr, token); - token.ThrowIfCancellationRequested(); - Array.Reverse(b); - return (TValue)(object)BitConverter.ToUInt64(b, 0); - } - - if (typeof(TValue) == typeof(short)) - { - var b = await s7Connector.ReadBytes(address.Operand, address.Start, 2, address.DbNr, token); - token.ThrowIfCancellationRequested(); - return (TValue)(object)(short)((b[0] << 8) + b[1]); - } - - if (typeof(TValue) == typeof(byte) || typeof(TValue) == typeof(char)) - { - var b = await s7Connector.ReadBytes(address.Operand, address.Start, 1, address.DbNr, token); - token.ThrowIfCancellationRequested(); - - return (TValue)(object)b[0]; - } - - if (typeof(TValue) == typeof(byte[])) - { - var b = await s7Connector.ReadBytes(address.Operand, address.Start, address.Length, address.DbNr, token); - token.ThrowIfCancellationRequested(); - return (TValue)(object)b; - } - - if (typeof(TValue) == typeof(double) || typeof(TValue) == typeof(float)) - { - var bytes = await s7Connector.ReadBytes(address.Operand, address.Start, 4, address.DbNr, token); - token.ThrowIfCancellationRequested(); - var d = BitConverter.ToSingle(bytes.Reverse().ToArray(),0); - return (TValue)(object)d; - } - - if (typeof(TValue) == typeof(string)) - { - if (address.Type == DbType.String) - { - var bytes = await s7Connector.ReadBytes(address.Operand, address.Start, 2, address.DbNr, token); - token.ThrowIfCancellationRequested(); - var stringLength = bytes[1]; - - var stringStartAddress = (ushort)(address.Start + 2); - var stringInBytes = await s7Connector.ReadBytes(address.Operand, stringStartAddress, stringLength, address.DbNr, token); - token.ThrowIfCancellationRequested(); - return (TValue)(object)Encoding.ASCII.GetString(stringInBytes); - } - else - { - var stringInBytes = await s7Connector.ReadBytes(address.Operand, address.Start, address.Length, address.DbNr, token); - token.ThrowIfCancellationRequested(); - return (TValue)(object)Encoding.ASCII.GetString(stringInBytes).Trim(); - } - } - - throw new InvalidOperationException(string.Format("type '{0}' not supported.", typeof(TValue))); - } - - - public Task SetValue(string variableName, TValue value) - { - return SetValue(variableName, value, CancellationToken.None); - } - - public async Task SetValue(string variableName, TValue value, CancellationToken token) - { - var address = varaibleNameParser.Parse(variableName); - if (address == null) throw new ArgumentException("Input variable name is not valid", "variableName"); - - if (typeof(TValue) == typeof(bool)) - { - await s7Connector.WriteBit(address.Operand, address.Start, address.Bit, (bool)(object)value, address.DbNr, token); - } - else if (typeof(TValue) == typeof(int) || typeof(TValue) == typeof(short)) - { - byte[] bytes; - if (address.Length == 4) - bytes = BitConverter.GetBytes((int)(object)value); - else - bytes = BitConverter.GetBytes((short)(object)value); - - Array.Reverse(bytes); - - await s7Connector.WriteBytes(address.Operand, address.Start, bytes, address.DbNr, token); - } - else if (typeof(TValue) == typeof(byte) || typeof(TValue) == typeof(char)) - { - var bytes = new[] { Convert.ToByte(value) }; - await s7Connector.WriteBytes(address.Operand, address.Start, bytes, address.DbNr, token); - } - else if (typeof(TValue) == typeof(byte[])) - { - await s7Connector.WriteBytes(address.Operand, address.Start, (byte[])(object)value, address.DbNr, token); - } - else if (typeof(TValue) == typeof(float)) - { - var buffer = new byte[sizeof(float)]; - S7.SetRealAt(buffer, 0, (float)(object)value); - await s7Connector.WriteBytes(address.Operand, address.Start, buffer, address.DbNr, token); - } - else if (typeof(TValue) == typeof(string)) - { - var stringValue = value as string; - if (stringValue == null) throw new ArgumentException("Value must be of type string", "value"); - - var bytes = Encoding.ASCII.GetBytes(stringValue); - Array.Resize(ref bytes, address.Length); - - if (address.Type == DbType.String) - { - var bytesWritten = await s7Connector.WriteBytes(address.Operand, address.Start, new[] { (byte)address.Length, (byte)bytes.Length }, address.DbNr, token); - token.ThrowIfCancellationRequested(); - if (bytesWritten == 2) - { - var stringStartAddress = (ushort)(address.Start + 2); - token.ThrowIfCancellationRequested(); - await s7Connector.WriteBytes(address.Operand, stringStartAddress, bytes, address.DbNr, token); - } - } - else - { - await s7Connector.WriteBytes(address.Operand, address.Start, bytes, address.DbNr, token); - token.ThrowIfCancellationRequested(); - } - } + if (multiVarRequestCycleTime < TimeSpan.FromMilliseconds(5)) + MultiVarRequestCycleTime = TimeSpan.FromMilliseconds(5); else + MultiVarRequestCycleTime = multiVarRequestCycleTime.Value; + } + } + + public IObservable ConnectionState { get; } + + public ILogger Logger + { + get => s7Connector.Logger; + set => s7Connector.Logger = value; + } + + public TimeSpan MultiVarRequestCycleTime { get; } = TimeSpan.FromSeconds(0.1); + + public int MultiVarRequestMaxItems { get; set; } = 16; + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Create an Observable for a given variable. Multiple notifications are automatically combined into a multi-variable subscription to + /// reduce network trafic and PLC workload. + /// + /// + /// + /// + /// + public IObservable CreateNotification(string variableName, TransmissionMode transmissionMode) + { + return Observable.Create(observer => + { + var address = ParseAndVerify(variableName, typeof(TValue)); + + var disp = new CompositeDisposable(); + var disposableContainer = multiVariableSubscriptions.GetOrCreateObservable(variableName); + disposableContainer.AddDisposableTo(disp); + + var observable = + // Read variable with GetValue first. + // This will propagate any errors due to reading from invalid addresses. + Observable.FromAsync(() => GetValue(variableName)) + .Concat( + disposableContainer.Observable + .Select(bytes => ValueConverter.ReadFromBuffer(bytes, address)) + ); + + if (transmissionMode == TransmissionMode.OnChange) + observable = observable.DistinctUntilChanged(); + + observable.Subscribe(observer) + .AddDisposableTo(disp); + + return disp; + }); + } + + /// + /// Read PLC variable as generic variable. + /// + /// + /// + /// + /// + public async Task GetValue(string variableName, CancellationToken token = default) + { + var address = ParseAndVerify(variableName, typeof(TValue)); + + var data = await s7Connector.ReadBytes(address.Operand, address.Start, address.BufferLength, address.DbNo, token); + return ValueConverter.ReadFromBuffer(data, address); + } + + /// + /// Read PLC variable as object. + /// The return type is automatically infered from the variable name. + /// + /// + /// + /// The actual return type is infered from the variable name. + public async Task GetValue(string variableName, CancellationToken token = default) + { + var address = variableNameParser.Parse(variableName); + var clrType = address.GetClrType(); + + var genericGetValue = getValueMethod!.MakeGenericMethod(clrType); + + var task = genericGetValue.Invoke(this, [variableName, token]) as Task; + + await task!; + var taskType = typeof(Task<>).MakeGenericType(clrType); + var propertyInfo = taskType.GetProperty(nameof(Task.Result)); + var result = propertyInfo!.GetValue(task); + + return result; + } + + /// + /// Write value to the PLC. + /// + /// + /// + /// + /// + /// + public async Task SetValue(string variableName, TValue value, CancellationToken token = default) + { + var address = ParseAndVerify(variableName, typeof(TValue)); + + if (typeof(TValue) == typeof(bool)) + { + // Special handling for bools, which are written on a by-bit basis. Writing a complete byte would + // overwrite other bits within this byte. + + await s7Connector.WriteBit(address.Operand, address.Start, address.Bit!.Value, (bool) (object) value, address.DbNo, token); + } + else + { + var buffer = arrayPool.Rent(address.BufferLength); + try { - throw new InvalidOperationException($"type '{typeof(TValue)}' not supported."); + ValueConverter.WriteToBuffer(buffer, value, address); + + await s7Connector.WriteBytes(address.Operand, address.Start, buffer, address.DbNo, address.BufferLength, token); + } + finally + { + ArrayPool.Shared.Return(buffer); } } + } - public IObservable CreateNotification(string variableName, TransmissionMode transmissionMode, TimeSpan cycleTime) + /// + /// Creates an observable of object for a variable. + /// The return type is automatically infered from the variable name. + /// + /// + /// + /// The return type is infered from the variable name. + public IObservable CreateNotification(string variableName, TransmissionMode transmissionMode) + { + var address = variableNameParser.Parse(variableName); + var clrType = address.GetClrType(); + + var genericCreateNotification = createNotificationMethod!.MakeGenericMethod(clrType); + + var genericNotification = genericCreateNotification.Invoke(this, [variableName, transmissionMode]); + + return SignatureConverter.ConvertToObjectObservable(genericNotification, clrType); + } + + /// + /// Trigger PLC connection and start notification loop. + /// + /// This method returns immediately and does not wait for the connection to be established. + /// + /// + /// Always true + [Obsolete($"Use {nameof(InitializeConnection)} or {nameof(TriggerConnection)}.")] + public async Task InitializeAsync() + { + await TriggerConnection(); + return true; + } + + + /// + /// Initialize PLC connection and wait for connection to be established. + /// + /// + /// + public async Task InitializeConnection(CancellationToken token = default) => await DoInitializeConnection(true, token); + + /// + /// Initialize PLC and trigger connection. This method will not wait for the connection to be established. + /// + /// + /// + public async Task TriggerConnection(CancellationToken token = default) => await DoInitializeConnection(false, token); + + protected virtual void Dispose(bool disposing) + { + if (disposed) return; + disposed = true; + + if (disposing) { - return Observable.Create(observer => + notificationSubscription?.Dispose(); + notificationSubscription = null; + + if (s7Connector != null) { - var address = varaibleNameParser.Parse(variableName); - if (address == null) throw new ArgumentException("Input variable name is not valid", nameof(variableName)); - - var disposables = new CompositeDisposable(); - var disposeableContainer = multiVariableSubscriptions.GetOrCreateObservable(variableName); - disposeableContainer.AddDisposableTo(disposables); - - var observable = disposeableContainer.Observable - .Select(bytes => ConvertToType(bytes, address)); - - if (transmissionMode == TransmissionMode.OnChange) - observable = observable.DistinctUntilChanged(); - - observable.Subscribe(observer) - .AddDisposableTo(disposables); - - return disposables; - }); - } - - public void Dispose() - { - Dispose(true); - } - - protected virtual void Dispose(bool disposing) - { - if (!disposed) - { - if (disposing) - { - Disposables.Dispose(); - - if (disposingSubject != null) - { - disposingSubject.OnNext(Unit.Default); - disposingSubject.OnCompleted(); - var disposable = (disposingSubject as IDisposable); - if (disposable != null) disposable.Dispose(); - disposingSubject = null; - } - if (s7Connector != null) - { - s7Connector.Disconnect().Wait(); - s7Connector.Dispose(); - s7Connector = null; - } - } - - disposed = true; - } - } - - ~Sharp7Plc() - { - Dispose(false); - } - - private IDisposable RunNotifications(IS7Connector connector, TimeSpan cycle) - { - return ConnectionState.FirstAsync() - .Select(states => states == Enums.ConnectionState.Connected) - .SelectMany(connected => GetAllValues(connected, connector)) - .RepeatAfterDelay(cycle) - .LogAndRetryAfterDelay(Logger, cycle, "Error while getting batch notifications from plc") - .TakeUntil(disposingSubject) - .Subscribe(); - } - - private async Task GetAllValues(bool connected, IS7Connector connector) - { - if (!connected) - return Unit.Default; - - if (multiVariableSubscriptions.ExistingKeys.IsEmpty()) - return Unit.Default; - - var stopWatch = Stopwatch.StartNew(); - foreach (var partsOfMultiVarRequest in multiVariableSubscriptions.ExistingKeys.Buffer(MultiVarRequestMaxItems)) - { - var multiVarRequest = await connector.ExecuteMultiVarRequest(partsOfMultiVarRequest); - - foreach (var pair in multiVarRequest) - { - if (multiVariableSubscriptions.TryGetObserver(pair.Key, out var subject)) - { - subject.OnNext(pair.Value); - } - } + s7Connector.Disconnect().Wait(); + s7Connector.Dispose(); + s7Connector = null; } - stopWatch.Stop(); - performanceCoutner.Add(stopWatch.ElapsedMilliseconds); + multiVariableSubscriptions.Dispose(); + } + } - PrintAndResetPerformanceStatistik(); + private async Task DoInitializeConnection(bool waitForConnection, CancellationToken token) + { + if (Interlocked.Exchange(ref initialized, 1) == 1) return; + await s7Connector.InitializeAsync(); + + // Triger connection. + // The initial connection might fail. In this case a reconnect is initiated. + // So we ignore any errors and wait for ConnectionState Connected afterward. + _ = Task.Run(async () => + { + try + { + await s7Connector.Connect(); + } + catch (Exception e) + { + Logger?.LogError(e, "Intiial PLC connection failed."); + } + }, token); + + if (waitForConnection) + await s7Connector.ConnectionState + .FirstAsync(c => c == Enums.ConnectionState.Connected) + .ToTask(token); + + StartNotificationLoop(); + } + + private async Task GetAllValues(Sharp7Connector connector) + { + if (multiVariableSubscriptions.ExistingKeys.IsEmpty()) return Unit.Default; - } - - private void PrintAndResetPerformanceStatistik() + + var stopWatch = Stopwatch.StartNew(); + foreach (var partsOfMultiVarRequest in multiVariableSubscriptions.ExistingKeys.Buffer(MultiVarRequestMaxItems)) { - if (performanceCoutner.Count == performanceCoutner.Capacity) - { - var average = performanceCoutner.Average(); - var min = performanceCoutner.Min(); - var max = performanceCoutner.Max(); + var multiVarRequest = await connector.ExecuteMultiVarRequest(partsOfMultiVarRequest as IReadOnlyList); - Logger?.LogTrace("Performance statistic during {0} elements of plc notification. Min: {1}, Max: {2}, Average: {3}, Plc: '{4}', Number of variables: {5}, Batch size: {6}", performanceCoutner.Capacity, min, max, average, plcConnectionSettings.IpAddress, multiVariableSubscriptions.ExistingKeys.Count(), - MultiVarRequestMaxItems); - performanceCoutner.Clear(); - } + foreach (var pair in multiVarRequest) + if (multiVariableSubscriptions.TryGetObserver(pair.Key, out var subject)) + subject.OnNext(pair.Value); } - public int MultiVarRequestMaxItems { get; set; } = 16; + stopWatch.Stop(); + performanceCounter.Add(stopWatch.ElapsedMilliseconds); + + PrintAndResetPerformanceStatistik(); + + return Unit.Default; + } + + private VariableAddress ParseAndVerify(string variableName, Type type) + { + var address = variableNameParser.Parse(variableName); + if (!address.MatchesType(type)) + throw new DataTypeMissmatchException($"Address \"{variableName}\" does not match type {type}.", type, address); + + return address; + } + + private void PrintAndResetPerformanceStatistik() + { + if (performanceCounter.Count == performanceCounter.Capacity) + { + var average = performanceCounter.Average(); + var min = performanceCounter.Min(); + var max = performanceCounter.Max(); + + Logger?.LogTrace("PLC {Plc} notification perf: {Elements} calls, min {Min}, max {Max}, avg {Avg}, variables {Vars}, batch size {BatchSize}", + plcConnectionSettings.IpAddress, + performanceCounter.Capacity, min, max, average, + multiVariableSubscriptions.ExistingKeys.Count(), + MultiVarRequestMaxItems); + performanceCounter.Clear(); + } + } + + private void StartNotificationLoop() + { + if (notificationSubscription != null) + // notification loop already running + return; + + var subscription = + ConnectionState + .FirstAsync(states => states == Enums.ConnectionState.Connected) + .SelectMany(_ => GetAllValues(s7Connector)) + .RepeatAfterDelay(MultiVarRequestCycleTime) + .LogAndRetryAfterDelay(Logger, MultiVarRequestCycleTime, "Error while getting batch notifications from plc") + .Subscribe(); + + if (Interlocked.CompareExchange(ref notificationSubscription, subscription, null) != null) + // Subscription has already been created (race condition). Dispose new subscription. + subscription.Dispose(); + } + + ~Sharp7Plc() + { + Dispose(false); } } diff --git a/Sharp7.Rx/Utils/SignatureConverter.cs b/Sharp7.Rx/Utils/SignatureConverter.cs new file mode 100644 index 0000000..64f4bf0 --- /dev/null +++ b/Sharp7.Rx/Utils/SignatureConverter.cs @@ -0,0 +1,21 @@ +using System.Reactive.Linq; +using System.Reflection; + +namespace Sharp7.Rx.Utils; + +internal static class SignatureConverter +{ + private static readonly MethodInfo convertToObjectObservableMethod = + typeof(SignatureConverter) + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .Single(m => m.Name == nameof(ConvertToObjectObservable) && m.GetGenericArguments().Length == 1); + + public static IObservable ConvertToObjectObservable(IObservable obs) => obs.Select(o => (object) o); + + public static IObservable ConvertToObjectObservable(object observable, Type sourceType) + { + var convertGeneric = convertToObjectObservableMethod.MakeGenericMethod(sourceType); + + return convertGeneric.Invoke(null, [observable]) as IObservable; + } +} diff --git a/Sharp7.Rx/ValueConverter.cs b/Sharp7.Rx/ValueConverter.cs new file mode 100644 index 0000000..0bd2059 --- /dev/null +++ b/Sharp7.Rx/ValueConverter.cs @@ -0,0 +1,174 @@ +using System.Buffers.Binary; +using System.Text; +using Sharp7.Rx.Enums; + +namespace Sharp7.Rx; + +internal static class ValueConverter +{ + private static readonly Dictionary writeFunctions = new() + { + { + typeof(bool), (data, address, value) => + { + var byteValue = (bool) value ? (byte) 1 : (byte) 0; + var shifted = (byte) (byteValue << address.Bit!); + data[0] = shifted; + } + }, + + {typeof(byte), (data, _, value) => data[0] = (byte) value}, + { + typeof(byte[]), (data, address, value) => + { + var source = (byte[]) value; + + var length = Math.Min(Math.Min(source.Length, data.Length), address.Length); + + source.AsSpan(0, length).CopyTo(data); + } + }, + + {typeof(short), (data, _, value) => BinaryPrimitives.WriteInt16BigEndian(data, (short) value)}, + {typeof(ushort), (data, _, value) => BinaryPrimitives.WriteUInt16BigEndian(data, (ushort) value)}, + {typeof(int), (data, _, value) => BinaryPrimitives.WriteInt32BigEndian(data, (int) value)}, + {typeof(uint), (data, _, value) => BinaryPrimitives.WriteUInt32BigEndian(data, (uint) value)}, + {typeof(long), (data, _, value) => BinaryPrimitives.WriteInt64BigEndian(data, (long) value)}, + {typeof(ulong), (data, _, value) => BinaryPrimitives.WriteUInt64BigEndian(data, (ulong) value)}, + + {typeof(float), (data, _, value) => BinaryPrimitives.WriteSingleBigEndian(data, (float) value)}, + {typeof(double), (data, _, value) => BinaryPrimitives.WriteDoubleBigEndian(data, (double) value)}, + + { + typeof(string), (data, address, value) => + { + if (value is not string stringValue) throw new ArgumentException("Value must be of type string", nameof(value)); + + + switch (address.Type) + { + case DbType.String: + EncodeString(data); + return; + case DbType.WString: + EncodeWString(data); + return; + case DbType.Byte: + + var readOnlySpan = stringValue.AsSpan(0, Math.Min(address.Length, stringValue.Length)); + Encoding.ASCII.GetBytes(readOnlySpan, data); + return; + default: + throw new DataTypeMissmatchException($"Cannot write string to {address.Type}", typeof(string), address); + } + + void EncodeString(Span span) + { + var encodedLength = Encoding.ASCII.GetByteCount(stringValue); + var length = Math.Min(address.Length, encodedLength); + + span[0] = (byte) address.Length; + span[1] = (byte) length; + + Encoding.ASCII.GetBytes(stringValue.AsSpan(0, length), span[2..]); + } + + void EncodeWString(Span span) + { + var length = Math.Min(address.Length, stringValue.Length); + + BinaryPrimitives.WriteUInt16BigEndian(span, address.Length); + BinaryPrimitives.WriteUInt16BigEndian(span[2..], (ushort) length); + + var readOnlySpan = stringValue.AsSpan(0, length); + Encoding.BigEndianUnicode.GetBytes(readOnlySpan, span[4..]); + } + } + } + }; + + private static readonly Dictionary readFunctions = new() + { + {typeof(bool), (buffer, address) => (buffer[0] >> address.Bit & 1) > 0}, + + {typeof(byte), (buffer, _) => buffer[0]}, + {typeof(byte[]), (buffer, _) => buffer.ToArray()}, + + {typeof(short), (buffer, _) => BinaryPrimitives.ReadInt16BigEndian(buffer)}, + {typeof(ushort), (buffer, _) => BinaryPrimitives.ReadUInt16BigEndian(buffer)}, + {typeof(int), (buffer, _) => BinaryPrimitives.ReadInt32BigEndian(buffer)}, + {typeof(uint), (buffer, _) => BinaryPrimitives.ReadUInt32BigEndian(buffer)}, + {typeof(long), (buffer, _) => BinaryPrimitives.ReadInt64BigEndian(buffer)}, + {typeof(ulong), (buffer, _) => BinaryPrimitives.ReadUInt64BigEndian(buffer)}, + {typeof(float), (buffer, _) => BinaryPrimitives.ReadSingleBigEndian(buffer)}, + {typeof(double), (buffer, _) => BinaryPrimitives.ReadDoubleBigEndian(buffer)}, + + { + typeof(string), (buffer, address) => + { + return address.Type switch + { + DbType.String => ParseString(buffer), + DbType.WString => ParseWString(buffer), + DbType.Byte => Encoding.ASCII.GetString(buffer), + _ => throw new DataTypeMissmatchException($"Cannot read string from {address.Type}", typeof(string), address) + }; + + string ParseString(Span data) + { + // First byte is maximal length + // Second byte is actual length + // https://support.industry.siemens.com/cs/mdm/109747174?c=94063831435&lc=de-DE + + var length = Math.Min(address.Length, data[1]); + + return Encoding.ASCII.GetString(data.Slice(2, length)); + } + + string ParseWString(Span data) + { + // First 2 bytes are maximal length + // Second 2 bytes are actual length + // https://support.industry.siemens.com/cs/mdm/109747174?c=94063855243&lc=de-DE + + // the length of the string is two bytes per character + var statedStringLength = BinaryPrimitives.ReadUInt16BigEndian(data.Slice(2, 2)); + var length = Math.Min(address.Length, statedStringLength) * 2; + + return Encoding.BigEndianUnicode.GetString(data.Slice(4, length)); + } + } + }, + }; + + public static TValue ReadFromBuffer(Span buffer, VariableAddress address) + { + if (buffer.Length < address.BufferLength) + throw new ArgumentException($"Buffer must be at least {address.BufferLength} bytes long for {address}", nameof(buffer)); + + var type = typeof(TValue); + + if (!readFunctions.TryGetValue(type, out var readFunc)) + throw new UnsupportedS7TypeException($"{type.Name} is not supported. {address}", type, address); + + var result = readFunc(buffer, address); + return (TValue) result; + } + + public static void WriteToBuffer(Span buffer, TValue value, VariableAddress address) + { + if (buffer.Length < address.BufferLength) + throw new ArgumentException($"Buffer must be at least {address.BufferLength} bytes long for {address}", nameof(buffer)); + + var type = typeof(TValue); + + if (!writeFunctions.TryGetValue(type, out var writeFunc)) + throw new UnsupportedS7TypeException($"{type.Name} is not supported. {address}", type, address); + + writeFunc(buffer, address, value); + } + + private delegate object ReadFunc(Span data, VariableAddress address); + + private delegate void WriteFunc(Span data, VariableAddress address, object value); +} diff --git a/Sharp7.Rx/VariableAddress.cs b/Sharp7.Rx/VariableAddress.cs new file mode 100644 index 0000000..8405916 --- /dev/null +++ b/Sharp7.Rx/VariableAddress.cs @@ -0,0 +1,32 @@ +using JetBrains.Annotations; +using Sharp7.Rx.Enums; + +namespace Sharp7.Rx; + +[NoReorder] +internal record VariableAddress(Operand Operand, ushort DbNo, DbType Type, ushort Start, ushort Length, byte? Bit = null) +{ + public Operand Operand { get; } = Operand; + public ushort DbNo { get; } = DbNo; + public ushort Start { get; } = Start; + public ushort Length { get; } = Length; + public byte? Bit { get; } = Bit; + public DbType Type { get; } = Type; + + public ushort BufferLength => Type switch + { + DbType.String => (ushort) (Length + 2), + DbType.WString => (ushort) (Length * 2 + 4), + _ => Length + }; + + public override string ToString() => + Type switch + { + DbType.Bit => $"{Operand}{DbNo}.{Type}{Start}.{Bit}", + DbType.String => $"{Operand}{DbNo}.{Type}{Start}.{Length}", + DbType.WString => $"{Operand}{DbNo}.{Type}{Start}.{Length}", + DbType.Byte => Length == 1 ? $"{Operand}{DbNo}.{Type}{Start}" : $"{Operand}{DbNo}.{Type}{Start}.{Length}", + _ => $"{Operand}{DbNo}.{Type}{Start}", + }; +} diff --git a/Sharp7.Rx/VariableNameParser.cs b/Sharp7.Rx/VariableNameParser.cs new file mode 100644 index 0000000..7191a48 --- /dev/null +++ b/Sharp7.Rx/VariableNameParser.cs @@ -0,0 +1,146 @@ +#nullable enable +using System.Globalization; +using System.Text.RegularExpressions; +using Sharp7.Rx.Enums; +using Sharp7.Rx.Interfaces; + +namespace Sharp7.Rx; + +internal class VariableNameParser : IVariableNameParser +{ + private static readonly Regex regex = new(@"^(?db)(?\d+)\.?(?[a-z]+)(?\d+)(\.(?\d+))?$", + RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly IReadOnlyDictionary types = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + {"bit", DbType.Bit}, + + {"string", DbType.String}, + {"wstring", DbType.WString}, + + {"byte", DbType.Byte}, + {"int", DbType.Int}, + {"uint", DbType.UInt}, + {"dint", DbType.DInt}, + {"udint", DbType.UDInt}, + {"lint", DbType.LInt}, + {"ulint", DbType.ULInt}, + + {"real", DbType.Single}, + {"lreal", DbType.Double}, + + // S7 notation + {"dbb", DbType.Byte}, + {"dbw", DbType.Int}, + {"dbx", DbType.Bit}, + {"dbd", DbType.DInt}, + + // used for legacy compatability + {"b", DbType.Byte}, + {"d", DbType.Single}, + {"dul", DbType.ULInt}, + {"dulint", DbType.ULInt}, + {"dulong", DbType.ULInt}, + {"s", DbType.String}, + {"w", DbType.Int}, + {"x", DbType.Bit}, + }; + + public VariableAddress Parse(string input) + { + ArgumentNullException.ThrowIfNull(input); + + var match = regex.Match(input); + if (!match.Success) + throw new InvalidS7AddressException($"Invalid S7 address \"{input}\". Expect format \"DB.(.)\".", input); + + var operand = (Operand) Enum.Parse(typeof(Operand), match.Groups["operand"].Value, true); + + if (!ushort.TryParse(match.Groups["dbNo"].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var dbNr)) + throw new InvalidS7AddressException($"\"{match.Groups["dbNo"].Value}\" is an invalid DB number in \"{input}\"", input); + + if (!ushort.TryParse(match.Groups["start"].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var start)) + throw new InvalidS7AddressException($"\"{match.Groups["start"].Value}\" is an invalid start bit in \"{input}\"", input); + + if (!types.TryGetValue(match.Groups["type"].Value, out var type)) + throw new InvalidS7AddressException($"\"{match.Groups["type"].Value}\" is an invalid type in \"{input}\"", input); + + ushort length = type switch + { + DbType.Bit => 1, + + DbType.String => GetLength(), + DbType.WString => GetLength(), + + DbType.Byte => GetLength(1), + + DbType.Int => 2, + DbType.DInt => 4, + DbType.ULInt => 8, + DbType.UInt => 2, + DbType.UDInt => 4, + DbType.LInt => 8, + + DbType.Single => 4, + DbType.Double => 8, + _ => throw new ArgumentOutOfRangeException($"DbType {type} is not supported") + }; + + switch (type) + { + case DbType.Bit: + case DbType.String: + case DbType.WString: + case DbType.Byte: + break; + case DbType.Int: + case DbType.UInt: + case DbType.DInt: + case DbType.UDInt: + case DbType.LInt: + case DbType.ULInt: + case DbType.Single: + case DbType.Double: + default: + if (match.Groups["bitOrLength"].Success) + throw new InvalidS7AddressException($"{type} address must not have a length: \"{input}\"", input); + break; + } + + byte? bit = type == DbType.Bit ? GetBit() : null; + + + var s7VariableAddress = new VariableAddress(Operand: operand, DbNo: dbNr, Type: type, Start: start, Length: length, Bit: bit); + + return s7VariableAddress; + + ushort GetLength(ushort? defaultValue = null) + { + if (!match.Groups["bitOrLength"].Success) + { + if (defaultValue.HasValue) + return defaultValue.Value; + throw new InvalidS7AddressException($"Variable of type {type} must have a length set \"{input}\"", input); + } + + if (!ushort.TryParse(match.Groups["bitOrLength"].Value, out var result)) + throw new InvalidS7AddressException($"\"{match.Groups["bitOrLength"].Value}\" is an invalid length in \"{input}\"", input); + + return result; + } + + byte GetBit() + { + if (!match.Groups["bitOrLength"].Success) + throw new InvalidS7AddressException($"Variable of type {type} must have a bit number set \"{input}\"", input); + + if (!byte.TryParse(match.Groups["bitOrLength"].Value, out var result)) + throw new InvalidS7AddressException($"\"{match.Groups["bitOrLength"].Value}\" is an invalid bit number in \"{input}\"", input); + + if (result > 7) + throw new InvalidS7AddressException($"Bit must be between 0 and 7 but is {result} in \"{input}\"", input); + + return result; + } + } +} diff --git a/Sharp7.Rx/linqpad-samples/Create Notification.linq b/Sharp7.Rx/linqpad-samples/Create Notification.linq new file mode 100644 index 0000000..9357004 --- /dev/null +++ b/Sharp7.Rx/linqpad-samples/Create Notification.linq @@ -0,0 +1,34 @@ + + Sharp7.Rx + Sharp7.Rx + System.Reactive.Linq + System.Reactive.Threading.Tasks + System.Threading.Tasks + + +var ip = "10.30.3.221"; // Set IP address of S7 +var db = 3; // Set to an existing DB + +// For rack number and cpu mpi address see +// https://github.com/fbarresi/Sharp7/wiki/Connection#rack-and-slot +var rackNumber = 0; +var cpuMpiAddress = 0; + +using var plc = new Sharp7Plc(ip, rackNumber, cpuMpiAddress); + +await plc.InitializeConnection(); + +"Connection established".Dump(); + +// create an IObservable +var observable = plc.CreateNotification($"DB{db}.Int6", Sharp7.Rx.Enums.TransmissionMode.OnChange); + +observable.Dump(); + +for (int i = 0; i < 10; i++) +{ + await plc.SetValue($"DB{db}.Int6", (short)i); + await Task.Delay(300); +} + + diff --git a/Sharp7.Rx/linqpad-samples/Establish connection.linq b/Sharp7.Rx/linqpad-samples/Establish connection.linq new file mode 100644 index 0000000..8b96b56 --- /dev/null +++ b/Sharp7.Rx/linqpad-samples/Establish connection.linq @@ -0,0 +1,39 @@ + + Sharp7.Rx + Sharp7.Rx + System.Reactive.Linq + System.Reactive.Threading.Tasks + System.Threading.Tasks + + +// Set IP address of S7 +var ip = "10.30.3.221"; + +// For rack number and cpu mpi address see +// https://github.com/fbarresi/Sharp7/wiki/Connection#rack-and-slot +var rackNumber = 0; +var cpuMpiAddress = 0; + +// Create Sharp7Plc +using var plc = new Sharp7Plc(ip, rackNumber, cpuMpiAddress); + +// Initialize connection +await plc.InitializeConnection(); + +// wait for connection to be established +await plc.ConnectionState + .FirstAsync(c => c == Sharp7.Rx.Enums.ConnectionState.Connected) + .ToTask(); + +"Connection established".Dump(); + +try +{ + await Task.Delay(Timeout.Infinite, this.QueryCancelToken); +} +catch (TaskCanceledException) +{ + "Script stopped by user. Disconnecting by disposing plc.".Dump(); +} + + diff --git a/Sharp7.Rx/linqpad-samples/FileOrder.txt b/Sharp7.Rx/linqpad-samples/FileOrder.txt new file mode 100644 index 0000000..6d18ca6 --- /dev/null +++ b/Sharp7.Rx/linqpad-samples/FileOrder.txt @@ -0,0 +1,4 @@ +Establish connection.linq +Write and read value.linq +Create Notification.linq +Multiple notifications.linq \ No newline at end of file diff --git a/Sharp7.Rx/linqpad-samples/Multiple notifications.linq b/Sharp7.Rx/linqpad-samples/Multiple notifications.linq new file mode 100644 index 0000000..6078d74 --- /dev/null +++ b/Sharp7.Rx/linqpad-samples/Multiple notifications.linq @@ -0,0 +1,44 @@ + + Sharp7.Rx + Sharp7.Rx + System.Reactive.Linq + System.Reactive.Threading.Tasks + System.Threading.Tasks + + +var ip = "10.30.3.221"; // Set IP address of S7 +var db = 3; // Set to an existing DB + +// For rack number and cpu mpi address see +// https://github.com/fbarresi/Sharp7/wiki/Connection#rack-and-slot +var rackNumber = 0; +var cpuMpiAddress = 0; + +using var plc = new Sharp7Plc(ip, rackNumber, cpuMpiAddress); + +plc.ConnectionState.Dump(); + +await plc.InitializeConnection(); + +// create an IObservable +plc.CreateNotification($"DB{db}.Int6", Sharp7.Rx.Enums.TransmissionMode.OnChange).Dump("Int 6"); +plc.CreateNotification($"DB{db}.Real10", Sharp7.Rx.Enums.TransmissionMode.OnChange).Dump("Real 10"); + + + +for (int i = 0; i < 15; i++) +{ + switch (i%3) + { + case 0: + await plc.SetValue($"DB{db}.Int6", (short)i); + break; + case 1: + await plc.SetValue($"DB{db}.Real10", i * 0.123f); + break; + } + + await Task.Delay(300); +} + + diff --git a/Sharp7.Rx/linqpad-samples/Write and read value.linq b/Sharp7.Rx/linqpad-samples/Write and read value.linq new file mode 100644 index 0000000..302bb3a --- /dev/null +++ b/Sharp7.Rx/linqpad-samples/Write and read value.linq @@ -0,0 +1,32 @@ + + Sharp7.Rx + Sharp7.Rx + System.Reactive.Linq + System.Reactive.Threading.Tasks + System.Threading.Tasks + + +var ip = "10.30.3.221"; // Set IP address of S7 +var db = 3; // Set to an existing DB + +// For rack number and cpu mpi address see +// https://github.com/fbarresi/Sharp7/wiki/Connection#rack-and-slot +var rackNumber = 0; +var cpuMpiAddress = 0; + +using var plc = new Sharp7Plc(ip, rackNumber, cpuMpiAddress); + +await plc.InitializeConnection(); + +"Connection established".Dump(); + +for (int i = 0; i < 10; i++) +{ + await plc.SetValue($"DB{db}.Int6", (short)i); + var value = await plc.GetValue($"DB{db}.Int6"); + value.Dump(); + + await Task.Delay(200); +} + +