diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4c19a31 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 8.0.x + 10.0.x + + - name: Restore + run: dotnet restore + + - name: Build + run: dotnet build --configuration Release --no-restore + + - name: Test + run: dotnet test --configuration Release --no-build --verbosity normal + + - name: Pack + run: dotnet pack src/CimonPlc/CimonPlc.csproj --configuration Release --no-build --output artifacts + + - uses: actions/upload-artifact@v4 + with: + name: nuget-package + path: artifacts/* diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index f691fe9..0000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,72 +0,0 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: "CodeQL" - -on: - push: - branches: [ "main" ] - pull_request: - # The branches below must be a subset of the branches above - branches: [ "main" ] - schedule: - - cron: '31 3 * * 5' - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - - strategy: - fail-fast: false - matrix: - language: [ 'csharp' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] - # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support - - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - - # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality - - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v2 - - # ℹ️ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - - # If the Autobuild fails above, remove it and uncomment the following three lines. - # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. - - # - run: | - # echo "Run, Build Application using script" - # ./location_of_script_within_repo/buildscript.sh - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index ade816b..f3698fc 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,22 +1,10 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: "CodeQL" +name: CodeQL on: push: - branches: [ "main" ] + branches: [ main ] pull_request: - # The branches below must be a subset of the branches above - branches: [ "main" ] + branches: [ main ] schedule: - cron: '42 12 * * 2' @@ -29,44 +17,15 @@ jobs: contents: read security-events: write - strategy: - fail-fast: false - matrix: - language: [ 'csharp' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] - # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - - # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality - - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v2 - - # ℹ️ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - - # If the Autobuild fails above, remove it and uncomment the following three lines. - # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + - name: Checkout repository + uses: actions/checkout@v5 - # - run: | - # echo "Run, Build Application using script" - # ./location_of_script_within_repo/buildscript.sh + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: csharp + build-mode: none - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f56c426 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,31 @@ +# Changelog + +## 2.0.0 + +### Fixed +- **Ethernet read requests were malformed**: the `Length` field of `ReadWordAsync`/`ReadBitAsync` frames was written as 1 byte instead of 2, + shifting the whole data block by one byte. Read frames now use the same `ID | FrameNo | Cmd | Res | Length(2) | Data | CheckSum` layout as write frames. +- `TcpSocket` could not reconnect after being disconnected (a closed `Socket` was reused), so with `autoConnect` every request after the first one failed + with `ObjectDisposedException`. A new socket is now created for each connection. +- Frame numbers did not wrap correctly: after 128 requests the frame number stayed `0` for the next 128 requests. It now cycles from 0 to 127. +- `SerialSocket.ReceiveData` returned a `null` task when a read was incomplete, which caused a `NullReferenceException` when awaited. +- Short, truncated or corrupted responses caused `IndexOutOfRangeException`; they now return `ResponseCode.WritingError`. +- Serial `WriteWordAsync` accepted up to 63 words although the 2-char length field can only describe 61; larger requests silently produced a wrong length. +- Opening an already open `SerialSocket` threw an exception. + +### Changed +- Responses are returned as soon as a complete frame arrives, instead of always waiting for the full read timeout (1 s by default) on every request. +- Connections opened explicitly with `Connect()` are no longer closed after each request when `autoConnect` is enabled; only connections opened automatically are closed. +- Requests on one connector are serialized, so a connector can be shared between concurrent tasks. +- `TcpSocket` has a new optional `usePing` parameter to skip the ICMP ping before connecting. +- `PlcConnector`, `TcpSocket` and `SerialSocket` implement `IDisposable`. +- Addresses are sent in upper case. +- Targets `netstandard2.0` and `net8.0` (was `netcoreapp3.1`, which is out of support), so the package also works on .NET Framework. +- Removed the `Ardalis.GuardClauses` dependency. +- **Breaking:** the helper classes `Tools` and the guard extensions are now internal. +- **Breaking:** `PlcConnector` constructor is now `protected PlcConnector(IPlcSocket socket, bool autoConnect)` and the protected `_timeout` field was removed. +- Fixed `dotnet pack`, which referenced an icon on a local disk path. +- Replaced duplicate CodeQL workflows with a single updated one, and added a CI workflow that builds, tests and packs the library. + +## 1.1.0 +- Downgrade project to .NET Core 3.1. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b7a787a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Mojtaba Kiani + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index f1b362f..2d86a01 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,224 @@ -[![NuGet version](https://badge.fury.io/nu/Cimon.Net.svg)](https://badge.fury.io/nu/Cimon.Net) - # Cimon.Net -A .NET Library for Cimon PLCs Connectivity - -## About The Project -

CIMON-PLC is an industrial control device based on international standards of IEC61131. There are many tools and servers to work with PLCs and send/recieve data with them, but what if we want to use our application to communicate? I Couldn't find any library to work with Cimon PLCs, and actually I used them in many projects. So I decided to publish my library.

+[![NuGet](https://img.shields.io/nuget/v/Cimon.Net.svg?logo=nuget&label=NuGet)](https://www.nuget.org/packages/Cimon.Net) +[![NuGet Downloads](https://img.shields.io/nuget/dt/Cimon.Net.svg?logo=nuget&label=Downloads)](https://www.nuget.org/packages/Cimon.Net) +[![CI](https://github.com/archofthings/Cimon.Net/actions/workflows/ci.yml/badge.svg)](https://github.com/archofthings/Cimon.Net/actions/workflows/ci.yml) +[![CodeQL](https://github.com/archofthings/Cimon.Net/actions/workflows/codeql.yml/badge.svg)](https://github.com/archofthings/Cimon.Net/actions/workflows/codeql.yml) +[![.NET Standard 2.0](https://img.shields.io/badge/.NET%20Standard-2.0-512BD4?logo=dotnet)](https://learn.microsoft.com/dotnet/standard/net-standard) +[![.NET 8](https://img.shields.io/badge/.NET-8.0-512BD4?logo=dotnet)](https://dotnet.microsoft.com/download/dotnet/8.0) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) + +A lightweight .NET library for communicating with **CIMON PLCs** over **Ethernet (TCP)** and **Serial (RS232C / RS485)**. +Read and write bits and words in PLC device memory with a simple async API. + +```csharp +using var plc = new EthernetConnector(new TcpSocket("192.168.1.10")); +var (code, words) = await plc.ReadWordAsync(MemoryType.D, "000100", 10); +``` + +## Contents + +- [About](#about) +- [Features](#features) +- [Installation](#installation) +- [Quick start](#quick-start) +- [Connection handling](#connection-handling) +- [Addresses and memory types](#addresses-and-memory-types) +- [Response codes](#response-codes) +- [Limits per request](#limits-per-request) +- [Supported PLCs and platforms](#supported-plcs-and-platforms) +- [Build and test](#build-and-test) +- [Contributing](#contributing) +- [License](#license) + +## About + +CIMON PLCs are industrial controllers based on the IEC 61131 standard. There are many tools and servers to exchange +data with PLCs, but what if you want your own application to talk to them directly? I couldn't find any library for +CIMON PLCs, even though I used them in many projects, so I decided to publish mine. + +## Features + +- **Ethernet (TCP)** and **Serial (RS232C / RS485)** connectors with the same API +- Read and write **bits** and **words** in any device memory (`X`, `Y`, `M`, `L`, `K`, `F`, `T`, `C`, `S`, `D`) +- Fully **async**, and safe to share one connector between concurrent tasks +- Responses are validated (header, frame number, checksum and length) before data is returned +- Automatic or manual connection management +- Targets **.NET Standard 2.0** and **.NET 8**, so it runs on .NET Framework 4.6.1+ as well as modern .NET +- No third-party dependencies besides `System.IO.Ports` + +## Installation + +Install from [NuGet](https://www.nuget.org/packages/Cimon.Net) with the .NET CLI: + +```shell +dotnet add package Cimon.Net +``` + +Or with the Package Manager Console in Visual Studio: + +```powershell +Install-Package Cimon.Net +``` + +## Quick start + +Create a connector for your connection type: `EthernetConnector` for Ethernet TCP, or `SerialConnector` for RS232C/RS485. + +### Ethernet + +```csharp +using CimonPlc.Enums; +using CimonPlc.PlcConnectors; +using CimonPlc.Sockets; + +using var plc = new EthernetConnector(new TcpSocket("192.168.1.10", 10620)); + +// Read 10 bits from input memory X, starting at address 000001 +var (responseCode, bits) = await plc.ReadBitAsync(MemoryType.X, "000001", 10); + +if (responseCode == ResponseCode.Success) + Console.WriteLine(string.Join(", ", bits)); + +// Write 3 words to data register D, starting at address 000100 +var writeResult = await plc.WriteWordAsync(MemoryType.D, "000100", 1200, 35, 65535); +``` + +### Serial + +```csharp +using var plc = new SerialConnector(new SerialSocket("COM3", 9600)); + +// Write 5 bits to output memory Y, starting at address 000010 +var responseCode = await plc.WriteBitAsync(MemoryType.Y, "000010", 1, 1, 1, 0, 1); + +// Read 4 words from data register D +var (code, words) = await plc.ReadWordAsync(MemoryType.D, "000200", 4); +``` + +### API overview + +| Method | Returns | Description | +|--------|---------|-------------| +| `ReadWordAsync(memoryType, address, length)` | `(ResponseCode, int[])` | Reads `length` 16-bit words | +| `ReadBitAsync(memoryType, address, length)` | `(ResponseCode, byte[])` | Reads `length` bits, each value is `0` or `1` | +| `WriteWordAsync(memoryType, address, params int[] data)` | `ResponseCode` | Writes words, each value in `0`–`65535` | +| `WriteBitAsync(memoryType, address, params byte[] data)` | `ResponseCode` | Writes bits, each value `0` or `1` | +| `Connect(readTimeout, writeTimeout, pingTimeout)` | `ConnectionStatus` | Opens the connection (timeouts in ms, 100–10,000) | +| `Disconnect()` | `ConnectionStatus` | Closes the connection | + +Invalid arguments (bad address format, out-of-range length or values) throw `ArgumentException`. +Communication problems are reported through the returned `ResponseCode`. + +## Connection handling + +With `autoConnect` enabled (the default), each read or write opens the connection if needed and closes it afterwards. +That's convenient for occasional requests. For frequent polling it's much faster to keep one connection open: + +```csharp +using var plc = new EthernetConnector(new TcpSocket("192.168.1.10")); + +var status = await plc.Connect(readTimeout: 1000, writeTimeout: 1000, pingTimeout: 3000); +if (status != ConnectionStatus.Connected) + return; + +while (!cancellationToken.IsCancellationRequested) +{ + var (code, words) = await plc.ReadWordAsync(MemoryType.D, "000100", 10); + // ... + await Task.Delay(500); +} + +plc.Disconnect(); +``` + +- A connection opened with `Connect()` stays open, even when `autoConnect` is enabled. +- With `autoConnect: false`, requests on a closed connection return `ResponseCode.SystemError`. +- Requests on one connector are sent one at a time, so a single connector can be shared between tasks. +- By default, `TcpSocket` pings the PLC before connecting and returns `ConnectionStatus.NoRouteToDestination` if there's no reply. + If your network blocks ICMP, turn the ping off: + +```csharp +var socket = new TcpSocket("192.168.1.10", 10620, usePing: false); +``` + +## Addresses and memory types + +Addresses are hexadecimal strings of up to 6 characters. Shorter addresses are padded with leading zeros, so `"A0"` becomes `"0000A0"`. + +- **Word** functions need a word address, so the last digit must be `0` (for example `"000010"`). +- **Bit** functions accept any bit address (for example `"0000A1"`). + +| Memory | Name | Description | +|--------|------|-------------| +| `X` | Input | Inputs received directly from devices | +| `Y` | Output | Outputs written to devices | +| `M` | Internal memory | Internal relays used by the logic | +| `L` | Link | Data link with upper and lower devices | +| `K` | Keep | Like `M`, but retained when power is off | +| `F` | Internal flag | System state, clock contacts and flags | +| `T` | Timer | Timer values | +| `C` | Counter | Counter values | +| `S` | Step control | Step control relays | +| `D` | Data register | Internal data storage | + +## Response codes + +Every request returns a `ResponseCode`. Anything other than `Success` means the request failed. + +| Code | Value | Meaning | +|------|-------|---------| +| `Success` | 0 | Request processed successfully | +| `SystemError` | 1 | System error, no connection or no response from the PLC | +| `InvalidDevicePrefix` | 2 | Invalid device prefix | +| `InvalidDeviceAddress` | 3 | Invalid device address | +| `UdpErrorReadDataSize` | 4 | Error in requested data size | +| `UdpErrorBlockSize` | 5 | More than 16 requested blocks | +| `BufferError` | 6 | Buffer memory reported an error in data or size | +| `OverBufferCapacity` | 7 | Receive buffer capacity exceeded | +| `OverSendingTime` | 8 | Sending time exceeded | +| `UdpInvalidHeader` | 9 | Invalid header | +| `ChecksumError` | 10 | Checksum error in the received data | +| `FrameSizeError` | 11 | Error in frame length | +| `UdpDataSizeError` | 12 | Error in the size of data to write | +| `UnknownBitValue` | 13 | Invalid bit value in write data | +| `UnknownCommand` | 14 | Unknown command | +| `WritingError` | 15 | Writing is disabled, or the response was invalid | +| `CpuError` | 16 | CPU processing error | + +## Limits per request + +| Function | Ethernet | Serial | +|----------|----------|--------| +| `ReadWordAsync` | 1–512 words | 1–63 words | +| `ReadBitAsync` | 1–1024 bits | 1–126 bits | +| `WriteWordAsync` | 1–64 words | 1–61 words | +| `WriteBitAsync` | 1–256 bits | 1–126 bits | + +## Supported PLCs and platforms + +- **PLCs:** the CIMON PLC range, including the `PLC-S`, `CP` and `XP` series +- **Frameworks:** .NET Standard 2.0 (.NET Framework 4.6.1+, .NET Core 2.0+, Mono) and .NET 8+ -## Getting Started -### 1. Installing Cimon.Net -You can install Cimon.Net with [NuGet Package Manager Console](https://www.nuget.org/packages/Cimon.Net): +More documentation is in the [Wiki](https://github.com/archofthings/Cimon.Net/wiki). - Install-Package Cimon.Net - -Or via the .NET Core command-line interface: +## Build and test - dotnet add package Cimon.Net - -Either commands, from Package Manager Console or .NET Core CLI, will download and install **Cimon.Net** and all required dependencies. +You need the [.NET SDK](https://dotnet.microsoft.com/download) 10 or newer. -### 2. Defining your Connector -Create a Connector based on your connection type, You can choose between `EthernetConnector` to support Ethernet TCP/UDP connection or `SerialConnector` to support RS232C/RS485 serial interfaces. +```shell +dotnet build +dotnet test +``` -`EthernetConnector` usage sample for reading 10 bits from Input device memory `X` address 000001: - - var Plc = new EthernetConnector(new TcpSocket("192.168.1.10", 10620), true); - var (responseCode, data) = await Plc.ReadBitAsync(MemoryType.X, "000001", 10); +`EthernetConnector` tests run against a local mock TCP server built with [Rony.Net](https://github.com/archofthings/Rony.Net). +`SerialConnector` tests use a fake serial socket, so no device is needed. -`SerialConnector` usage sample for write 5 bits to Output device memory `Y` address 000010: - - var Plc = new SerialConnector(new SerialSocket("COM3", 9600), true); - await Plc.WriteBitAsync(MemoryType.Y, "000010", 1, 1, 1, 0, 1); - -## Documentation -Check the Wiki and feel free to edit it: https://github.com/MojtabaKiani/Cimon.Net/wiki +## Contributing -## Supported PLCs -Complete range of Cimon PLC products including `PLC-S`, `CP`, `XP` series +Issues and pull requests are welcome. Please run `dotnet test` before opening a pull request, and add tests for new behavior. +See [CHANGELOG.md](CHANGELOG.md) for the release history. -## Compile -You need at least Visual Studio 2019 (you can download the Community Edition for free). +## License -## Running the tests -I used my library [Rony.Net](https://github.com/MojtabaKiani/Rony.Net) in unit tests for `EthernetConnector`. But for `SerialConnector` -I used no device or library and it only works with a fake socket. +Cimon.Net is released under the [MIT License](LICENSE). diff --git a/src/CimonPlc/CimonPlc.csproj b/src/CimonPlc/CimonPlc.csproj index 13850ec..cedf90f 100644 --- a/src/CimonPlc/CimonPlc.csproj +++ b/src/CimonPlc/CimonPlc.csproj @@ -1,32 +1,43 @@ - + + + + netstandard2.0;net8.0 + latest + CimonPlc + true + $(NoWarn);CS1591 + true + - netcoreapp3.1 Cimon.Net + 2.0.0 Mojtaba Kiani Cimon.Net - A .NET Library for Cimon PLCs Connectivity - https://github.com/MojtabaKiani/Cimon.Net - Cimon, Cymon, PLc, Industrial + A .NET Library for Cimon PLCs Connectivity over Ethernet (TCP) and Serial (RS232C/RS485) + Cimon;PLC;Industrial;Automation;Ethernet;Serial en - cimon_logo.png MIT - https://github.com/MojtabaKiani/Cimon.Net + https://github.com/archofthings/Cimon.Net + https://github.com/archofthings/Cimon.Net git - Downgarde project to .Net Core 3.1 to handle more users - 1.1.0 + README.md + true + true + snupkg + See https://github.com/archofthings/Cimon.Net/blob/main/CHANGELOG.md - - + + + + + - - True - - + diff --git a/src/CimonPlc/Enums/ResponseCode.cs b/src/CimonPlc/Enums/ResponseCode.cs index 9bcdb97..b9068d6 100644 --- a/src/CimonPlc/Enums/ResponseCode.cs +++ b/src/CimonPlc/Enums/ResponseCode.cs @@ -1,7 +1,7 @@ namespace CimonPlc.Enums { /// - /// Error Code From Cimon PLC on read & write + /// Error Code From Cimon PLC on read & write /// public enum ResponseCode { diff --git a/src/CimonPlc/Interfaces/IEthernetSocket.cs b/src/CimonPlc/Interfaces/IEthernetSocket.cs index ad75f93..5960bd8 100644 --- a/src/CimonPlc/Interfaces/IEthernetSocket.cs +++ b/src/CimonPlc/Interfaces/IEthernetSocket.cs @@ -1,6 +1,3 @@ -using CimonPlc.Enums; -using System.Threading.Tasks; - namespace CimonPlc.Interfaces { public interface IEthernetSocket : IPlcSocket diff --git a/src/CimonPlc/Interfaces/IPlcSocket.cs b/src/CimonPlc/Interfaces/IPlcSocket.cs index a2ce874..f1af1a6 100644 --- a/src/CimonPlc/Interfaces/IPlcSocket.cs +++ b/src/CimonPlc/Interfaces/IPlcSocket.cs @@ -1,11 +1,11 @@ -using CimonPlc.Enums; +using CimonPlc.Enums; using System.Threading.Tasks; namespace CimonPlc.Interfaces { public interface IPlcSocket { - public bool IsConnected { get; } + bool IsConnected { get; } Task Connect(int readTimeout = 1000, int writeTimeout = 1000, int pingTimeout = 3000); @@ -13,6 +13,11 @@ public interface IPlcSocket Task SendData(byte[] frame); + /// + /// Waits for the response of the last sent frame. + /// Implementations should return as soon as a complete frame is received, or when the read timeout expires. + /// + /// The received frame, or null if nothing was received Task ReceiveData(); } } diff --git a/src/CimonPlc/Interfaces/ISerialSocket.cs b/src/CimonPlc/Interfaces/ISerialSocket.cs index fe061f8..b02c52c 100644 --- a/src/CimonPlc/Interfaces/ISerialSocket.cs +++ b/src/CimonPlc/Interfaces/ISerialSocket.cs @@ -1,6 +1,3 @@ -using CimonPlc.Enums; -using System.Threading.Tasks; - namespace CimonPlc.Interfaces { public interface ISerialSocket : IPlcSocket diff --git a/src/CimonPlc/Internal/Guard.cs b/src/CimonPlc/Internal/Guard.cs new file mode 100644 index 0000000..a2476de --- /dev/null +++ b/src/CimonPlc/Internal/Guard.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace CimonPlc +{ + /// + /// Lightweight argument guards used to validate public API input. + /// + internal sealed class Guard + { + public static readonly Guard Against = new Guard(); + + private Guard() + { + } + + public T Null(T input, string parameterName) where T : class + { + if (input is null) + throw new ArgumentNullException(parameterName); + + return input; + } + + public string NullOrEmpty(string input, string parameterName) + { + Null(input, parameterName); + if (input.Length == 0) + throw new ArgumentException($"Required input {parameterName} was empty.", parameterName); + + return input; + } + + public T[] NullOrEmpty(T[] input, string parameterName) + { + if (input is null) + throw new ArgumentNullException(parameterName); + if (input.Length == 0) + throw new ArgumentException($"Required input {parameterName} was empty.", parameterName); + + return input; + } + + public int OutOfRange(int input, string parameterName, int rangeFrom, int rangeTo) + { + if (input < rangeFrom || input > rangeTo) + throw new ArgumentOutOfRangeException(parameterName, $"Input {parameterName} was out of range, it must be between {rangeFrom} and {rangeTo}."); + + return input; + } + + public IEnumerable OutOfRange(IEnumerable input, string parameterName, T rangeFrom, T rangeTo) where T : IComparable + { + var comparer = Comparer.Default; + + if (comparer.Compare(rangeFrom, rangeTo) >= 0) + throw new ArgumentException($"{nameof(rangeFrom)} should be less than {nameof(rangeTo)}"); + + if (input.Any(x => comparer.Compare(x, rangeFrom) < 0 || comparer.Compare(x, rangeTo) > 0)) + throw new ArgumentOutOfRangeException(parameterName, $"Input {parameterName} was out of range, it must be between {rangeFrom} and {rangeTo}."); + + return input; + } + + public string BadFormat(string input, string parameterName, string regexPattern) + { + Null(input, parameterName); + if (!Regex.IsMatch(input, $"^(?:{regexPattern})$")) + throw new ArgumentException($"Input {parameterName} was not in required format", parameterName); + + return input; + } + + public T InvalidData(T input, string parameterName, Func predicate) + { + if (!predicate(input)) + throw new ArgumentException($"Input {parameterName} did not satisfy the options", parameterName); + + return input; + } + + public TEnum UndefinedEnum(TEnum input, string parameterName) where TEnum : struct, Enum + { + if (!Enum.IsDefined(typeof(TEnum), input)) + throw new ArgumentOutOfRangeException(parameterName, $"Input {parameterName} is not a defined {typeof(TEnum).Name} value."); + + return input; + } + } +} diff --git a/src/CimonPlc/Internal/Tools.cs b/src/CimonPlc/Internal/Tools.cs new file mode 100644 index 0000000..5b917e5 --- /dev/null +++ b/src/CimonPlc/Internal/Tools.cs @@ -0,0 +1,164 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Net.NetworkInformation; +using System.Text; + +namespace CimonPlc +{ + internal static class Tools + { + public static IPStatus Ping(IPAddress ip, int pingTimeout = 300) + { + try + { + using var pingSender = new Ping(); + var reply = pingSender.Send(ip, pingTimeout); + return reply.Status; + } + catch + { + return IPStatus.BadDestination; + } + } + + public static int ToInt(byte byte1, byte byte2) + { + return (byte1 << 8) + byte2; + } + + public static byte ToByte(char char1, char char2) + { + return byte.Parse(string.Concat(char1, char2), NumberStyles.HexNumber, CultureInfo.InvariantCulture); + } + + public static byte[] ToDualByte(this int number) + { + number &= 0xFFFF; + return new byte[] { (byte)(number >> 8), (byte)(number & 0xFF) }; + } + + public static char[] ToDualChar(this byte number) + { + return number.ToString("X2", CultureInfo.InvariantCulture).ToCharArray(); + } + + public static char[] ToQuadChar(this int number) + { + number &= 0xFFFF; + return number.ToString("X4", CultureInfo.InvariantCulture).ToCharArray(); + } + + /// + /// Calculates the BCC of a serial frame and appends it as two hex chars. + /// The first 3 chars (header + station number) are excluded from the calculation. + /// + public static void AddBCC(this List input) + { + if (input == null || input.Count == 0) + return; + + var sum = input.Skip(3).Sum(x => Convert.ToByte(x)); + sum %= 256; + + input.AddRange(((byte)sum).ToDualChar()); + } + + /// + /// Two-byte checksum used in Ethernet frames: the lower 2 bytes of the byte-wise sum. + /// + public static byte[] CheckSum(IEnumerable frame) + { + return frame.Sum(x => x).ToDualByte(); + } + + /// + /// Validates the header and checksum of an Ethernet response frame. + /// + public static bool IsValidResponse(byte[] buffer, byte frameNo) + { + if (buffer == null || buffer.Length < EthernetFrame.MinimumLength) + return false; + + //[0-8] ID : This is a 9 - byte string "KDT_PLC_S". + if (Encoding.ASCII.GetString(buffer, 0, 9) != EthernetFrame.SlaveId) + return false; + + //[9] Frame No : The value that 128 are added to the number of the + // command frame received from the Master is used + if (buffer[9] - 128 != frameNo) + return false; + + //[12-13] Length : The frame must be long enough to hold the declared data + if (buffer.Length < EthernetFrame.ExpectedLength(buffer)) + return false; + + //Check Sum : This is 2-byte value. After the entire frame is binary-summed by the byte, + //the lower 2 - byte in the result value is used. + var checkSum = CheckSum(buffer.Take(buffer.Length - 2)); + return checkSum[0] == buffer[buffer.Length - 2] && checkSum[1] == buffer[buffer.Length - 1]; + } + + /// + /// Validates the framing (STX ... ETX) and command of a serial response frame. + /// + public static bool IsValidSerialResponse(char[] receivedFrame, byte ackCommand) + { + if (receivedFrame == null || receivedFrame.Length < SerialFrame.MinimumLength) + return false; + + if (receivedFrame[0] != SerialFrame.Stx || receivedFrame[receivedFrame.Length - 1] != SerialFrame.Etx) + return false; + + //[3] Cmd : In Slave, 1 - byte command must be equal to sent command + return receivedFrame[3] == (char)ackCommand; + } + + public static bool IsHex(char c) + { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + } + } + + internal static class EthernetFrame + { + public const string MasterId = "KDT_PLC_M"; + public const string SlaveId = "KDT_PLC_S"; + + /// ID(9) + Frame No(1) + Cmd(1) + Res(1) + Length(2) + public const int HeaderLength = 14; + + public const int CheckSumLength = 2; + + public const int MinimumLength = HeaderLength + CheckSumLength; + + /// Command used by the PLC to acknowledge writes and to report errors. + public const byte AckCommand = 0x41; + + /// + /// Total length of a frame according to the length field in its header, + /// or -1 if the header is not complete yet. + /// + public static int ExpectedLength(IReadOnlyList buffer) + { + if (buffer.Count < HeaderLength) + return -1; + + return HeaderLength + Tools.ToInt(buffer[12], buffer[13]) + CheckSumLength; + } + } + + internal static class SerialFrame + { + public const char Enq = (char)0x05; + public const char Eot = (char)0x04; + public const char Stx = (char)0x02; + public const char Etx = (char)0x03; + public const char ErrorCommand = 'E'; + + /// STX(1) + Station(2) + Cmd(1) + Length/Code(2) + ETX(1) + public const int MinimumLength = 7; + } +} diff --git a/src/CimonPlc/Models/PlcConnector.cs b/src/CimonPlc/Models/PlcConnector.cs index 4ef90d8..9a0a81c 100644 --- a/src/CimonPlc/Models/PlcConnector.cs +++ b/src/CimonPlc/Models/PlcConnector.cs @@ -1,72 +1,147 @@ -using Ardalis.GuardClauses; using CimonPlc.Enums; using CimonPlc.Interfaces; using System; +using System.Threading; using System.Threading.Tasks; namespace CimonPlc.Models { - public abstract class PlcConnector + public abstract class PlcConnector : IDisposable { - protected IPlcSocket _socket; - protected bool _autoConnect; - protected int _timeout; + protected readonly IPlcSocket _socket; + protected readonly bool _autoConnect; + + // PLC protocols are strictly request/response, so only one exchange may be in flight per connection. + private readonly SemaphoreSlim _exchangeLock = new SemaphoreSlim(1, 1); + private bool _disposed; public bool IsConnected => _socket.IsConnected; - public PlcConnector(IPlcSocket socket) + /// The socket used to talk to the PLC + /// + /// If true, read/write functions open the connection when it is not open and + /// close it again after the request is completed. + /// + protected PlcConnector(IPlcSocket socket, bool autoConnect = true) { - _socket = socket; + _socket = Guard.Against.Null(socket, nameof(socket)); + _autoConnect = autoConnect; } /// - /// Creates a connection to PLC using a network socket, before read or write data it must be called. + /// Creates a connection to PLC using a network socket. It must be called before reading or writing data, + /// unless the connector is created with auto connect enabled. /// - /// Data read timeout in ms, valid rage is between 100 and 10,000 - /// Data write timeout in ms, valid rage is between 100 and 10,000 - /// Ping Timeout in ms, valid rage is between 100 and 10,000 - /// Returns success if it can connect to PLC successfully - public virtual async Task Connect(int readTimeout = 1000, int writeTimeout = 1000, int pingTimeout = 3000) + /// Data read timeout in ms, valid range is between 100 and 10,000 + /// Data write timeout in ms, valid range is between 100 and 10,000 + /// Ping Timeout in ms, valid range is between 100 and 10,000 + /// Returns Connected if it can connect to PLC successfully + public virtual Task Connect(int readTimeout = 1000, int writeTimeout = 1000, int pingTimeout = 3000) { Guard.Against.OutOfRange(readTimeout, nameof(readTimeout), 100, 10000); Guard.Against.OutOfRange(writeTimeout, nameof(writeTimeout), 100, 10000); Guard.Against.OutOfRange(pingTimeout, nameof(pingTimeout), 100, 10000); + ThrowIfDisposed(); - try - { - _timeout = readTimeout; - return await _socket.Connect(readTimeout, writeTimeout, pingTimeout); - } - catch (Exception) - { - throw; - } - + return _socket.Connect(readTimeout, writeTimeout, pingTimeout); } /// - /// Drops the connection to PLC, it should call if you don't set auto connection in read/write functions. + /// Drops the connection to PLC. It should be called when auto connect is not used. /// - /// Returns success if it can disconnect from PLC successfully + /// Returns DisConnected if it can disconnect from PLC successfully public virtual ConnectionStatus Disconnect() { + return _socket.Disconnect(); + } + + public abstract Task<(ResponseCode responseCode, int[] data)> ReadWordAsync(MemoryType memoryType, string address, int length); + + public abstract Task<(ResponseCode responseCode, byte[] data)> ReadBitAsync(MemoryType memoryType, string address, int length); + + public abstract Task WriteWordAsync(MemoryType memoryType, string address, params int[] data); + + public abstract Task WriteBitAsync(MemoryType memoryType, string address, params byte[] data); + + /// + /// Sends a request frame to the PLC and waits for its response, taking care of auto connect. + /// + /// + /// The response frame on success; otherwise a null frame and the + /// error code describing why the exchange failed. + /// + protected async Task<(ResponseCode error, byte[] response)> ExchangeAsync(byte[] request) + { + ThrowIfDisposed(); + + await _exchangeLock.WaitAsync().ConfigureAwait(false); + var openedHere = false; try { - return _socket.Disconnect(); + if (!IsConnected) + { + if (!_autoConnect) + return (ResponseCode.SystemError, null); + + if (await Connect().ConfigureAwait(false) != ConnectionStatus.Connected) + return (ResponseCode.SystemError, null); + + openedHere = true; + } + + if (!await _socket.SendData(request).ConfigureAwait(false)) + return (ResponseCode.WritingError, null); + + var response = await _socket.ReceiveData().ConfigureAwait(false); + if (response == null || response.Length == 0) + return (ResponseCode.SystemError, null); + + return (ResponseCode.Success, response); } - catch (Exception) + finally { - throw; + // Only close connections that were opened automatically, so callers who + // connect explicitly can keep a persistent connection. + if (openedHere) + _socket.Disconnect(); + + _exchangeLock.Release(); } } - public abstract Task<(ResponseCode responseCode, int[] data)> ReadWordAsync(MemoryType memoryType, string address, int length); + protected static string NormalizeAddress(string address) + { + return address.PadLeft(6, '0').ToUpperInvariant(); + } - public abstract Task<(ResponseCode responseCode, byte[] data)> ReadBitAsync(MemoryType memoryType, string address, int length); + protected void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException(GetType().Name); + } - public abstract Task WriteWordAsync(MemoryType memoryType, string address, params int[] data); + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } - public abstract Task WriteBitAsync(MemoryType memoryType, string address, params byte[] data); + protected virtual void Dispose(bool disposing) + { + if (_disposed) + return; + if (disposing) + { + if (_socket is IDisposable disposable) + disposable.Dispose(); + else + _socket.Disconnect(); + + _exchangeLock.Dispose(); + } + + _disposed = true; + } } } diff --git a/src/CimonPlc/PlcConnectors/EthernetConnector.cs b/src/CimonPlc/PlcConnectors/EthernetConnector.cs index e38cc81..72d60ae 100644 --- a/src/CimonPlc/PlcConnectors/EthernetConnector.cs +++ b/src/CimonPlc/PlcConnectors/EthernetConnector.cs @@ -1,7 +1,6 @@ -using Ardalis.GuardClauses; +using System; using CimonPlc.Interfaces; using CimonPlc.Enums; -using System; using System.Threading.Tasks; using System.Collections.Generic; using System.Text; @@ -12,13 +11,29 @@ namespace CimonPlc.PlcConnectors { public class EthernetConnector : PlcConnector { - private byte _frameNo = 0; - public byte FrameNo => (byte)(_frameNo++ > 127 ? 0 : _frameNo); + private const int MaxFrameNo = 127; + private readonly object _frameNoLock = new object(); + private byte _frameNo; - public EthernetConnector(IEthernetSocket socket, bool autoConnect = true) : base(socket) + public EthernetConnector(IEthernetSocket socket, bool autoConnect = true) : base(socket, autoConnect) { - _socket = socket; - _autoConnect = autoConnect; + } + + /// + /// Number of the next frame sent to PLC, in the range from 0 to 127. + /// Reading this property consumes the number. + /// + public byte FrameNo + { + get + { + lock (_frameNoLock) + { + var frameNo = _frameNo; + _frameNo = (byte)(_frameNo >= MaxFrameNo ? 0 : _frameNo + 1); + return frameNo; + } + } } /// @@ -29,92 +44,23 @@ public EthernetConnector(IEthernetSocket socket, bool autoConnect = true) : base /// PLC memory which required to read such as X or D /// The word address or the card number of a corresponding device is used. That is, in case of bit device such as X/Y, the last number should be '0' and should contains 6 character, such as '000010' /// Requested length for reading memory. Length must be in the range from 1 to 512 - /// Returns a tuple includes PLC response code and an array of byte contains read data + /// Returns a tuple includes PLC response code and an array of words contains read data public override async Task<(ResponseCode responseCode, int[] data)> ReadWordAsync(MemoryType memoryType, string address, int length) { - Guard.Against.Null(memoryType, nameof(memoryType)); + Guard.Against.UndefinedEnum(memoryType, nameof(memoryType)); Guard.Against.OutOfRange(length, nameof(length), 1, 512); - Guard.Against.BadFormat(address, nameof(address), @"[0-9a-fA-F]{0,5}0"); - - - while (address.Length < 6) - address = "0" + address; - - if (!IsConnected && !_autoConnect) - return (ResponseCode.SystemError, null); - - if (!IsConnected) - { - var connectionStatus = await Connect(); - if (connectionStatus != ConnectionStatus.Connected) - return (ResponseCode.SystemError, null); - } - - var frame = new List(); - - //[0-8] ID : This is a 9 - byte string “ KDT_PLC_M ”. - frame.AddRange(Encoding.ASCII.GetBytes("KDT_PLC_M")); - - //[9] Frame No : This, 1 - byte data with the range from 0 to 127 - frame.Add(FrameNo); - - //[10] Cmd : In Master, 1 - byte command can be used and the - //format of ‘Data’ field is selected according to each command - frame.Add((byte)ReadCommand.WordBlockRead); - - //[11] Res: Reserved. (1 Byte, 00h) – Reserved device - frame.Add(0); - - //[12] Length : Total number of the bytes of a frame data device - frame.Add(10); - - //[13-n] Data : This is n block based on data needs to write and contains 2 parts : - // 1- [13-20] Memory Address : 8 bytes - frame.Add((byte)memoryType); - frame.Add(0); - frame.AddRange(Encoding.ASCII.GetBytes(address)); - - // 2- [21-22] Read Length : 2 bytes - frame.AddRange(length.ToDualByte()); - - //Check Sum : This is 2-byte value. After the entire frame is binary-summed by the byte, - //the lower 2 - byte in the result value is used. - frame.AddRange(frame.Sum(x => x).ToDualByte()); - - try - { - var sendState = await _socket.SendData(frame.ToArray()); - if (!sendState) - return (ResponseCode.WritingError, null); - - //Read response from PLC - await Task.Delay(_timeout); - var receivedFrame = await _socket.ReceiveData(); - if (receivedFrame == null) - return (ResponseCode.SystemError, null); - - if (_autoConnect) - _socket.Disconnect(); + Guard.Against.BadFormat(address, nameof(address), "[0-9a-fA-F]{0,5}0"); - //If response's cmd equals to 0x41, then must return error code - if (receivedFrame[10] == 0x41) - return ((ResponseCode)Tools.ToInt(receivedFrame[14], receivedFrame[15]), null); + var (error, response) = await SendCommandAsync((byte)ReadCommand.WordBlockRead, memoryType, address, length).ConfigureAwait(false); + if (error != ResponseCode.Success) + return (error, null); - if (!Tools.IsValidResponse(receivedFrame, frame[9], (byte)ReadCommand.WordBlockRead)) - return (ResponseCode.WritingError, null); + var data = ReadData(response); + var words = new int[data.Length / 2]; + for (var i = 0; i < words.Length; i++) + words[i] = Tools.ToInt(data[i * 2], data[i * 2 + 1]); - var tempArray = new List(); - const int dataStartIndex = 23; - for (var x = dataStartIndex; x < receivedFrame.Length - 2; x += 2) - { - tempArray.Add(Tools.ToInt(receivedFrame[x], receivedFrame[x + 1])); - } - return (ResponseCode.Success, tempArray.ToArray()); - } - catch (Exception) - { - throw; - } + return (ResponseCode.Success, words); } /// @@ -128,90 +74,15 @@ public EthernetConnector(IEthernetSocket socket, bool autoConnect = true) : base /// Returns a tuple includes PLC response code and an array of byte contains read data public override async Task<(ResponseCode responseCode, byte[] data)> ReadBitAsync(MemoryType memoryType, string address, int length) { - Guard.Against.Null(memoryType, nameof(memoryType)); + Guard.Against.UndefinedEnum(memoryType, nameof(memoryType)); Guard.Against.OutOfRange(length, nameof(length), 1, 1024); Guard.Against.BadFormat(address, nameof(address), "[0-9a-fA-F]{1,6}"); + var (error, response) = await SendCommandAsync((byte)ReadCommand.BitBlockRead, memoryType, address, length).ConfigureAwait(false); + if (error != ResponseCode.Success) + return (error, null); - while (address.Length < 6) - address = "0" + address; - - if (!IsConnected && !_autoConnect) - return (ResponseCode.SystemError, null); - - if (!IsConnected) - { - var connectionStatus = await Connect(); - if (connectionStatus != ConnectionStatus.Connected) - return (ResponseCode.SystemError, null); - } - - var frame = new List(); - - //[0-8] ID : This is a 9 - byte string “ KDT_PLC_M ”. - frame.AddRange(Encoding.ASCII.GetBytes("KDT_PLC_M")); - - //[9] Frame No : This, 1 - byte data with the range from 0 to 127 - frame.Add(FrameNo); - - //[10] Cmd : In Master, 1 - byte command can be used and the - //format of ‘Data’ field is selected according to each command - frame.Add((byte)ReadCommand.BitBlockRead); - - //[11] Res: Reserved. (1 Byte, 00h) – Reserved device - frame.Add(0); - - //[12] Length : Total number of the bytes of a frame data device - frame.Add(10); - - //[13-n] Data : This is n block based on data needs to write and contains 2 parts : - // 1- [13-20] Memory Address : 8 bytes - frame.Add((byte)memoryType); - frame.Add(0); - frame.AddRange(Encoding.ASCII.GetBytes(address)); - - // 2- [21-22] Read Length : 2 bytes - frame.AddRange(length.ToDualByte()); - - //Check Sum : This is 2-byte value. After the entire frame is binary-summed by the byte, - //the lower 2 - byte in the result value is used. - frame.AddRange(frame.Sum(x => x).ToDualByte()); - - try - { - var sendState = await _socket.SendData(frame.ToArray()); - if (!sendState) - return (ResponseCode.WritingError, null); - - //Read response from PLC - await Task.Delay(_timeout); - var receivedFrame = await _socket.ReceiveData(); - if (receivedFrame == null) - return (ResponseCode.SystemError, null); - - if (_autoConnect) - _socket.Disconnect(); - - //If response's cmd equals to 0x41, then must return error code - if (receivedFrame[10] == 0x41) - return ((ResponseCode)Tools.ToInt(receivedFrame[14], receivedFrame[15]), null); - - if (!Tools.IsValidResponse(receivedFrame, frame[9], (byte)ReadCommand.BitBlockRead)) - return (ResponseCode.WritingError, null); - - //Data includes : Address 9 chars + data - var dataLength = Tools.ToInt(receivedFrame[12], receivedFrame[13]); - const int addressLength = 9; - var tempArray = new byte[dataLength - addressLength]; - - const int dataStartIndex = 23; - Array.Copy(receivedFrame, dataStartIndex, tempArray, 0, tempArray.Length); - return (ResponseCode.Success, tempArray); - } - catch (Exception) - { - throw; - } + return (ResponseCode.Success, ReadData(response)); } /// @@ -228,84 +99,12 @@ public override async Task WriteWordAsync(MemoryType memoryType, s Guard.Against.NullOrEmpty(data, nameof(data)); Guard.Against.OutOfRange(data, nameof(data), 0, 0xFFFF); Guard.Against.OutOfRange(data.Length, nameof(data), 1, 64); - Guard.Against.Null(memoryType, nameof(memoryType)); + Guard.Against.UndefinedEnum(memoryType, nameof(memoryType)); Guard.Against.BadFormat(address, nameof(address), "[0-9a-fA-F]{0,5}0"); - while (address.Length < 6) - address = "0" + address; - - if (!IsConnected && !_autoConnect) - return ResponseCode.SystemError; - - if (!IsConnected) - { - var connectionStatus = await Connect(); - if (connectionStatus != ConnectionStatus.Connected) - return ResponseCode.SystemError; - } - - var frame = new List(); - - //[0-8] ID : This is a 9 - byte string “ KDT_PLC_M ”. - frame.AddRange(Encoding.ASCII.GetBytes("KDT_PLC_M")); - - //[9] Frame No : This, 1 - byte data with the range from 0 to 127 - frame.Add(FrameNo); - - //[10] Cmd : In Master, 1 - byte command can be used and the - //format of ‘Data’ field is selected according to each command - frame.Add((byte)WriteCommands.WordBlockWrite); - - //[11] Res: Reserved. (1 Byte, 00h) – Reserved device - frame.Add(0); - - //[12-13] Length : This is the 2 - byte value indicating the size of ‘Data’ field. (Hexadecimal Figure) - int dataLength = data.Length * 2 + 10; - frame.AddRange(dataLength.ToDualByte()); - - //[14-n] Data : This is n block based on data needs to write and contains 3 parts : - // 1- [14-21] Memory Address : 8 bytes - frame.Add((byte)memoryType); - frame.Add(0); - frame.AddRange(Encoding.ASCII.GetBytes(address)); - - // 2- [22-23] Write Size : 2 bytes - frame.AddRange(data.Length.ToDualByte()); - - // 3- [24-n] Word Data : n bytes - foreach (var item in data) - { - frame.AddRange(item.ToDualByte()); - } - - //Check Sum : This is 2-byte value. After the entire frame is binary-summed by the byte, - //the lower 2 - byte in the result value is used. - frame.AddRange(frame.Sum(x => x).ToDualByte()); - - try - { - var sendState = await _socket.SendData(frame.ToArray()); - if (!sendState) - return ResponseCode.WritingError; - - //Read response from PLC - await Task.Delay(_timeout); - var receivedFrame = await _socket.ReceiveData(); - if (receivedFrame == null) - return ResponseCode.SystemError; - - if (_autoConnect) - _socket.Disconnect(); - - if (!Tools.IsValidResponse(receivedFrame, frame[9], 0x41)) - return ResponseCode.WritingError; - - return (ResponseCode)Tools.ToInt(receivedFrame[14], receivedFrame[15]); - } - catch (Exception) - { - throw; - } + var payload = data.SelectMany(x => x.ToDualByte()); + var (error, _) = await SendCommandAsync((byte)WriteCommands.WordBlockWrite, memoryType, address, data.Length, payload).ConfigureAwait(false); + return error; } /// @@ -315,89 +114,110 @@ public override async Task WriteWordAsync(MemoryType memoryType, s /// /// PLC memory which required to write such as Y or D /// The word address or the card number of a corresponding device is used, it should contains 6 characters, such as '0000D1' - /// Data needed to write on PLC memory, The total sum of word data is not to be over 256 bits - /// Returns a code which shows PLC response co`de + /// Data needed to write on PLC memory, each item must be 0 or 1. The total sum of data is not to be over 256 bits + /// Returns a code which shows PLC response code public override async Task WriteBitAsync(MemoryType memoryType, string address, params byte[] data) { Guard.Against.NullOrEmpty(data, nameof(data)); Guard.Against.OutOfRange(data, nameof(data), 0, 1); Guard.Against.OutOfRange(data.Length, nameof(data), 1, 256); - Guard.Against.Null(memoryType, nameof(memoryType)); + Guard.Against.UndefinedEnum(memoryType, nameof(memoryType)); Guard.Against.BadFormat(address, nameof(address), "[0-9a-fA-F]{1,6}"); - while (address.Length < 6) - address = "0" + address; + var (error, _) = await SendCommandAsync((byte)WriteCommands.BitBlockWrite, memoryType, address, data.Length, data).ConfigureAwait(false); + return error; + } + + /// + /// Builds a master frame, sends it and validates the PLC response. + /// + /// Success and the response frame, or the error code returned by PLC or detected locally + private async Task<(ResponseCode error, byte[] response)> SendCommandAsync(byte command, MemoryType memoryType, string address, int count, IEnumerable payload = null) + { + var frameNo = FrameNo; + var frame = BuildFrame(frameNo, command, memoryType, address, count, payload); - if (!IsConnected && !_autoConnect) - return ResponseCode.SystemError; + var (error, response) = await ExchangeAsync(frame).ConfigureAwait(false); + if (error != ResponseCode.Success) + return (error, null); - if (!IsConnected) + if (!Tools.IsValidResponse(response, frameNo)) + return (ResponseCode.WritingError, null); + + //[10] Cmd : For writes the PLC always answers with 0x41 and a response code. + //For reads, 0x41 means the request failed and a response code is returned instead of data. + var isWrite = command == (byte)WriteCommands.WordBlockWrite || command == (byte)WriteCommands.BitBlockWrite; + if (response[10] == EthernetFrame.AckCommand) { - var connectionStatus = await Connect(); - if (connectionStatus != ConnectionStatus.Connected) - return ResponseCode.SystemError; + if (response.Length < EthernetFrame.HeaderLength + 2 + EthernetFrame.CheckSumLength) + return (ResponseCode.WritingError, null); + + var code = (ResponseCode)Tools.ToInt(response[14], response[15]); + if (!isWrite && code == ResponseCode.Success) + return (ResponseCode.WritingError, null); + + return (code, response); } - var frame = new List(); + if (isWrite || response[10] != command) + return (ResponseCode.WritingError, null); - //[0-8] ID : This is a 9 - byte string “ KDT_PLC_M ”. - frame.AddRange(Encoding.ASCII.GetBytes("KDT_PLC_M")); + return (ResponseCode.Success, response); + } - //[9] Frame No : This, 1 - byte data with the range from 0 to 127 - frame.Add(FrameNo); + internal static byte[] BuildFrame(byte frameNo, byte command, MemoryType memoryType, string address, int count, IEnumerable payload = null) + { + //[14-n] Data : This is n block and contains up to 3 parts : + // 1- Memory Address : 8 bytes, device type + 0 + 6 chars address + // 2- Read/Write Size : 2 bytes + // 3- Data to write : n bytes (write commands only) + var data = new List { (byte)memoryType, 0 }; + data.AddRange(Encoding.ASCII.GetBytes(NormalizeAddress(address))); + data.AddRange(count.ToDualByte()); + if (payload != null) + data.AddRange(payload); - //[10] Cmd : In Master, 1 - byte command can be used and the - //format of ‘Data’ field is selected according to each command - frame.Add((byte)WriteCommands.BitBlockWrite); + var frame = new List(EthernetFrame.MinimumLength + data.Count); - //[11] Res: Reserved. (1 Byte, 00h) – Reserved device - frame.Add(0); + //[0-8] ID : This is a 9 - byte string "KDT_PLC_M". + frame.AddRange(Encoding.ASCII.GetBytes(EthernetFrame.MasterId)); - //[12-13] Length : This is the 2 - byte value indicating the size of ‘Data’ field. (Hexadecimal Figure) - int dataLength = data.Length + 10; - frame.AddRange(dataLength.ToDualByte()); + //[9] Frame No : This, 1 - byte data with the range from 0 to 127 + frame.Add(frameNo); + + //[10] Cmd : In Master, 1 - byte command can be used and the + //format of 'Data' field is selected according to each command + frame.Add(command); - //[14-n] Data : This is n block based on data needs to write and contains 3 parts : - // 1- [14-21] Memory Address : 8 bytes - frame.Add((byte)memoryType); + //[11] Res: Reserved. (1 Byte, 00h) frame.Add(0); - frame.AddRange(Encoding.ASCII.GetBytes(address)); - // 2- [22-23] Write Size : 2 bytes - frame.AddRange(data.Length.ToDualByte()); + //[12-13] Length : This is the 2 - byte value indicating the size of 'Data' field. + frame.AddRange(data.Count.ToDualByte()); - // 3- [24-n] Word Data : n bytes frame.AddRange(data); //Check Sum : This is 2-byte value. After the entire frame is binary-summed by the byte, //the lower 2 - byte in the result value is used. - frame.AddRange(frame.Sum(x => x).ToDualByte()); - - try - { - var sendState = await _socket.SendData(frame.ToArray()); - if (!sendState) - return ResponseCode.WritingError; - - //Read response from PLC - await Task.Delay(_timeout); - var receivedFrame = await _socket.ReceiveData(); - if (receivedFrame == null) - return ResponseCode.SystemError; - - if (_autoConnect) - _socket.Disconnect(); + frame.AddRange(Tools.CheckSum(frame)); - if (!Tools.IsValidResponse(receivedFrame, frame[9], 0x41)) - return ResponseCode.WritingError; - - return (ResponseCode)Tools.ToInt(receivedFrame[14], receivedFrame[15]); + return frame.ToArray(); + } - } - catch (Exception) - { - throw; - } + /// + /// Extracts read data from a response frame. The data field starts with the 9 bytes + /// memory address block, followed by the read values. + /// + private static byte[] ReadData(byte[] response) + { + const int addressLength = 9; + var dataLength = Tools.ToInt(response[12], response[13]) - addressLength; + if (dataLength <= 0) + return new byte[0]; + + var data = new byte[dataLength]; + Array.Copy(response, EthernetFrame.HeaderLength + addressLength, data, 0, dataLength); + return data; } } } diff --git a/src/CimonPlc/PlcConnectors/SerialConnector.cs b/src/CimonPlc/PlcConnectors/SerialConnector.cs index db9a88b..004866e 100644 --- a/src/CimonPlc/PlcConnectors/SerialConnector.cs +++ b/src/CimonPlc/PlcConnectors/SerialConnector.cs @@ -1,10 +1,8 @@ -using Ardalis.GuardClauses; using CimonPlc.Interfaces; using CimonPlc.Enums; using System; using System.Threading.Tasks; using System.Collections.Generic; -using System.Text; using System.Linq; using CimonPlc.Models; @@ -12,115 +10,42 @@ namespace CimonPlc.PlcConnectors { public class SerialConnector : PlcConnector { - public SerialConnector(ISerialSocket socket, bool autoConnect = true) : base(socket) + public SerialConnector(ISerialSocket socket, bool autoConnect = true) : base(socket, autoConnect) { - _socket = socket; - _autoConnect = autoConnect; } /// /// This function is to assign PLC device memory directly to read according to /// memory data type.The total sum of the word data should be not over 63 words. /// - /// PLC memory which required to read. Valid symbols are X, Y, M, L, K, F, Z, TC, TS, CC, CS, D, S + /// PLC memory which required to read. Valid symbols are X, Y, M, L, K, F, T, C, D, S /// The word address or the card number of a corresponding device is used. That is, in case of bit device such as X/Y, the last number should be '0' and should contains 6 character, such as '000010' /// Requested length for reading memory. Length must be in the range from 1 to 63 - /// Returns a tuple includes PLC response code and an array of byte contains read data + /// Returns a tuple includes PLC response code and an array of words contains read data public override async Task<(ResponseCode responseCode, int[] data)> ReadWordAsync(MemoryType memoryType, string address, int length) { - Guard.Against.Null(memoryType, nameof(memoryType)); + Guard.Against.UndefinedEnum(memoryType, nameof(memoryType)); Guard.Against.OutOfRange(length, nameof(length), 1, 63); - Guard.Against.BadFormat(address, nameof(address), @"[0-9a-fA-F]{0,5}0"); - - - while (address.Length < 6) - address = "0" + address; + Guard.Against.BadFormat(address, nameof(address), "[0-9a-fA-F]{0,5}0"); - if (!IsConnected && !_autoConnect) - return (ResponseCode.SystemError, null); + var (error, response) = await SendCommandAsync((byte)ReadCommand.WordBlockRead, memoryType, address, length).ConfigureAwait(false); + if (error != ResponseCode.Success) + return (error, null); - if (!IsConnected) + // Each word is sent as 4 hex chars + var data = ReadData(response); + var words = new int[data.Length / 4]; + for (var i = 0; i < words.Length; i++) { - var connectionStatus = await Connect(); - if (connectionStatus != ConnectionStatus.Connected) - return (ResponseCode.SystemError, null); + var x = i * 4; + words[i] = Tools.ToInt(Tools.ToByte(data[x], data[x + 1]), Tools.ToByte(data[x + 2], data[x + 3])); } - var frame = new List(); - - //[0] HEADER : This is 1-byte control letter of ASCII code “ENQ”. - frame.Add((char)0x5); - - //[1-2] PLC Station Number : This, 2 - byte data with the range from 0 to 127, if station numbers are assigned, multi-drop communication is available - frame.Add('0'); - frame.Add('0'); - - //[3] Cmd : In Master, 1 - byte command can be used and the - //format of ‘Data’ field is selected according to each command - frame.Add((char)ReadCommand.WordBlockRead); - - //[4-5] Length : Total number of the chars of a frame data device - // It contains 8 chars for address + 2 chars for length - frame.Add('0'); - frame.Add('A'); - - //[6-15] Data : This is n block based on data needs to write and contains 2 parts : - // 1- [6-13] Memory Address : 8 chars - frame.Add(memoryType.ToString()[0]); - frame.Add('0'); - frame.AddRange(address.ToCharArray()); - - // 2- [14-15] Read Length : 2 chars - frame.AddRange(((byte)length).ToDualChar()); - - //[16-17] BCC : is the remainder value when dividing the binary-sum from Cmd to the end of data by 256. - frame.AddBCC(); - - //[18] End : This is 1-byte control letter of ASCII code “EOT”. - frame.Add((char)0x4); - - try - { - var sendState = await _socket.SendData(frame.Select(Convert.ToByte).ToArray()); - if (!sendState) - return (ResponseCode.WritingError, null); - - //Read response from PLC - await Task.Delay(_timeout); - var tempFrame = await _socket.ReceiveData(); - if (tempFrame == null || tempFrame == Array.Empty()) - return (ResponseCode.SystemError, null); - var receivedFrame = tempFrame.Select(x => (char)x).ToArray(); - - if (_autoConnect) - _socket.Disconnect(); - - //If response's cmd equals to E(0X45), then must return error code - if (receivedFrame[3] == 'E') - return ((ResponseCode)Tools.ToByte(receivedFrame[6], receivedFrame[7]), null); - - if (!Tools.IsValidSerialResponse(receivedFrame, (byte)ReadCommand.WordBlockRead)) - return (ResponseCode.WritingError, null); - - var tempArray = new List(); - const int dataStartIndex = 6; - int dataLength = Tools.ToByte(receivedFrame[4], receivedFrame[5]); - for (var x = dataStartIndex; x < dataLength + dataStartIndex; x += 4) - { - var byte1 = Tools.ToByte(receivedFrame[x], receivedFrame[x + 1]); - var byte2 = Tools.ToByte(receivedFrame[x+2], receivedFrame[x + 3]); - tempArray.Add(Tools.ToInt(byte1, byte2)); - } - return (ResponseCode.Success, tempArray.ToArray()); - } - catch (Exception) - { - throw; - } + return (ResponseCode.Success, words); } /// - /// This function is to assign PLC device memory directly to read bit block. + /// This function is to assign PLC device memory directly to read bit block. /// The total sum of the data is not to be over 126 bits. /// /// PLC memory which required to read such as X or D @@ -129,191 +54,43 @@ public SerialConnector(ISerialSocket socket, bool autoConnect = true) : base(soc /// Returns a tuple includes PLC response code and an array of byte contains read data public override async Task<(ResponseCode responseCode, byte[] data)> ReadBitAsync(MemoryType memoryType, string address, int length) { - Guard.Against.Null(memoryType, nameof(memoryType)); + Guard.Against.UndefinedEnum(memoryType, nameof(memoryType)); Guard.Against.OutOfRange(length, nameof(length), 1, 126); - Guard.Against.BadFormat(address, nameof(address), @"[0-9a-fA-F]{1,6}"); - - - while (address.Length < 6) - address = "0" + address; - - if (!IsConnected && !_autoConnect) - return (ResponseCode.SystemError, null); - - if (!IsConnected) - { - var connectionStatus = await Connect(); - if (connectionStatus != ConnectionStatus.Connected) - return (ResponseCode.SystemError, null); - } - - var frame = new List(); - - //[0] HEADER : This is 1-byte control letter of ASCII code “ENQ”. - frame.Add((char)0x5); - - //[1-2] PLC Station Number : This, 2 - byte data with the range from 0 to 127, if station numbers are assigned, multi-drop communication is available - frame.Add('0'); - frame.Add('0'); - - //[3] Cmd : In Master, 1 - byte command can be used and the - //format of ‘Data’ field is selected according to each command - frame.Add((char)ReadCommand.BitBlockRead); - - //[4-5] Length : Total number of the chars of a frame data device - // It contains 8 chars for address + 2 chars for length - frame.Add('0'); - frame.Add('A'); - - //[6-15] Data : This is n block based on data needs to write and contains 2 parts : - // 1- [6-13] Memory Address : 8 chars - frame.Add(memoryType.ToString()[0]); - frame.Add('0'); - frame.AddRange(address.ToCharArray()); - - // 2- [14-15] Read Length : 2 chars - frame.AddRange(((byte)length).ToDualChar()); - - //[16-17] BCC : is the remainder value when dividing the binary-sum from Cmd to the end of data by 256. - frame.AddBCC(); + Guard.Against.BadFormat(address, nameof(address), "[0-9a-fA-F]{1,6}"); - //[18] End : This is 1-byte control letter of ASCII code “EOT”. - frame.Add((char)0x4); + var (error, response) = await SendCommandAsync((byte)ReadCommand.BitBlockRead, memoryType, address, length).ConfigureAwait(false); + if (error != ResponseCode.Success) + return (error, null); - try - { - var sendState = await _socket.SendData(frame.Select(Convert.ToByte).ToArray()); - if (!sendState) - return (ResponseCode.WritingError, null); - - //Read response from PLC - await Task.Delay(_timeout); - var tempFrame = await _socket.ReceiveData(); - if (tempFrame == null || tempFrame == Array.Empty()) - return (ResponseCode.SystemError, null); - var receivedFrame = tempFrame.Select(x => (char)x).ToArray(); - - if (_autoConnect) - _socket.Disconnect(); - - //If response's cmd equals to E(0X45), then must return error code - if (receivedFrame[3] == 'E') - return ((ResponseCode)Tools.ToByte(receivedFrame[6], receivedFrame[7]), null); - - if (!Tools.IsValidSerialResponse(receivedFrame, (byte)ReadCommand.BitBlockRead)) - return (ResponseCode.WritingError, null); - - var tempArray = new List(); - const int dataStartIndex = 6; - int dataLength = Tools.ToByte(receivedFrame[4], receivedFrame[5]); - for (var x = dataStartIndex; x < dataLength + dataStartIndex; x += 2) - { - tempArray.Add(Tools.ToByte(receivedFrame[x], receivedFrame[x + 1])); - } - return (ResponseCode.Success, tempArray.Select(x => (byte)x).ToArray()); + // Each bit is sent as 2 hex chars + var data = ReadData(response); + var bits = new byte[data.Length / 2]; + for (var i = 0; i < bits.Length; i++) + bits[i] = Tools.ToByte(data[i * 2], data[i * 2 + 1]); - } - catch (Exception) - { - throw; - } + return (ResponseCode.Success, bits); } /// /// This function is to assign PLC device memory directly to write according to - /// memory data type. The total sum of word data is not to be over 63 words. + /// memory data type. The total sum of word data is not to be over 61 words. /// /// PLC memory which required to write such as Y or D /// The word address or the card number of a corresponding device is used. That is, in case of bit device such as X/Y, the last number should be '0' and should contains 6 characters, such as '000010' - /// Data needed to write on PLC memory, The total sum of word data is not to be over 64 words + /// Data needed to write on PLC memory. The frame length field is limited to 255 chars, so up to 61 words can be written at once /// Returns a code which shows PLC response code public override async Task WriteWordAsync(MemoryType memoryType, string address, params int[] data) { Guard.Against.NullOrEmpty(data, nameof(data)); Guard.Against.OutOfRange(data, nameof(data), 0, 0xFFFF); - Guard.Against.OutOfRange(data.Length, nameof(data), 1, 63); - Guard.Against.Null(memoryType, nameof(memoryType)); + Guard.Against.OutOfRange(data.Length, nameof(data), 1, 61); + Guard.Against.UndefinedEnum(memoryType, nameof(memoryType)); Guard.Against.BadFormat(address, nameof(address), "[0-9a-fA-F]{0,5}0"); - while (address.Length < 6) - address = "0" + address; - - if (!IsConnected && !_autoConnect) - return ResponseCode.SystemError; - - if (!IsConnected) - { - var connectionStatus = await Connect(); - if (connectionStatus != ConnectionStatus.Connected) - return ResponseCode.SystemError; - } - - var frame = new List(); - - //[0] HEADER : This is 1-byte control letter of ASCII code “ENQ”. - frame.Add((char)0x5); - - //[1-2] PLC Station Number : This, 2 - byte data with the range from 0 to 127, if station numbers are assigned, multi-drop communication is available - frame.Add('0'); - frame.Add('0'); - - //[3] Cmd : In Master, 1 - byte command can be used and the - //format of ‘Data’ field is selected according to each command - frame.Add((char)WriteCommands.WordBlockWrite); - - //[4-5] Length : Total number of the chars of a frame data device - // It contains 8 chars for address + 2 chars word count + 4 chars for each word - var length = 10 + data.Length * 4 ; - frame.AddRange(((byte)length).ToDualChar()); - - //[6-15] Data : This is n block based on data needs to write and contains 3 parts : - // 1- [6-13] Memory Address : 8 chars - frame.Add(memoryType.ToString()[0]); - frame.Add('0'); - frame.AddRange(address.ToCharArray()); - - // 2- [14-15] Read Length : 2 chars - frame.AddRange(((byte)data.Length).ToDualChar()); - - // 3- [15-n] Words to write : 4 chars per word - foreach (var word in data) - frame.AddRange(word.ToQuadChar()); - - //[^3-^2] BCC : is the remainder value when dividing the binary-sum from Cmd to the end of data by 256. - frame.AddBCC(); - - //[^1] End : This is 1-byte control letter of ASCII code “EOT”. - frame.Add((char)0x4); - - try - { - var sendState = await _socket.SendData(frame.Select(Convert.ToByte).ToArray()); - if (!sendState) - return ResponseCode.WritingError; - - //Read response from PLC - await Task.Delay(_timeout); - var tempFrame = await _socket.ReceiveData(); - if (tempFrame == null || tempFrame == Array.Empty()) - return ResponseCode.SystemError; - var receivedFrame = tempFrame.Select(x => (char)x).ToArray(); - - if (_autoConnect) - _socket.Disconnect(); - - //If response's cmd equals to E(0X45), then must return error code - if (receivedFrame[3] == 'E') - return (ResponseCode)Tools.ToByte(receivedFrame[6], receivedFrame[7]); - - if (!Tools.IsValidSerialResponse(receivedFrame, (byte)WriteCommands.WordBlockWrite)) - return ResponseCode.WritingError; - - return (ResponseCode)Tools.ToByte(receivedFrame[4], receivedFrame[5]); - } - catch (Exception) - { - throw; - } + // 4 chars per word + var payload = data.SelectMany(x => x.ToQuadChar()); + var (error, _) = await SendCommandAsync((byte)WriteCommands.WordBlockWrite, memoryType, address, data.Length, payload).ConfigureAwait(false); + return error; } /// @@ -322,95 +99,111 @@ public override async Task WriteWordAsync(MemoryType memoryType, s /// /// PLC memory which required to write such as Y or D /// The word address or the card number of a corresponding device is used, it should contains 6 characters, such as '0000D1' - /// Data needed to write on PLC memory, The total sum of word data is not to be over 256 bits - /// Returns a code which shows PLC response co`de + /// Data needed to write on PLC memory, each item must be 0 or 1. The total sum of data is not to be over 126 bits + /// Returns a code which shows PLC response code public override async Task WriteBitAsync(MemoryType memoryType, string address, params byte[] data) { Guard.Against.NullOrEmpty(data, nameof(data)); Guard.Against.OutOfRange(data, nameof(data), 0, 1); Guard.Against.OutOfRange(data.Length, nameof(data), 1, 126); - Guard.Against.Null(memoryType, nameof(memoryType)); + Guard.Against.UndefinedEnum(memoryType, nameof(memoryType)); Guard.Against.BadFormat(address, nameof(address), "[0-9a-fA-F]{1,6}"); - while (address.Length < 6) - address = "0" + address; + // 1 char per bit + var payload = data.Select(x => (char)x); + var (error, _) = await SendCommandAsync((byte)WriteCommands.BitBlockWrite, memoryType, address, data.Length, payload).ConfigureAwait(false); + return error; + } - if (!IsConnected && !_autoConnect) - return ResponseCode.SystemError; + /// + /// Builds a master frame, sends it and validates the PLC response. + /// + /// Success and the response frame, or the error code returned by PLC or detected locally + private async Task<(ResponseCode error, char[] response)> SendCommandAsync(byte command, MemoryType memoryType, string address, int count, IEnumerable payload = null) + { + var frame = BuildFrame(command, memoryType, address, count, payload); - if (!IsConnected) - { - var connectionStatus = await Connect(); - if (connectionStatus != ConnectionStatus.Connected) - return ResponseCode.SystemError; - } + var (error, rawResponse) = await ExchangeAsync(frame).ConfigureAwait(false); + if (error != ResponseCode.Success) + return (error, null); + + var response = rawResponse.Select(x => (char)x).ToArray(); - var frame = new List(); + //If response's cmd equals to E(0x45), then the error code is in [6-7] + if (response.Length >= 8 && response[3] == SerialFrame.ErrorCommand) + return (ParseCode(response[6], response[7]), null); - //[0] HEADER : This is 1-byte control letter of ASCII code “ENQ”. - frame.Add((char)0x5); + if (!Tools.IsValidSerialResponse(response, command)) + return (ResponseCode.WritingError, null); - //[1-2] PLC Station Number : This, 2 - byte data with the range from 0 to 127, if station numbers are assigned, multi-drop communication is available + var isWrite = command == (byte)WriteCommands.WordBlockWrite || command == (byte)WriteCommands.BitBlockWrite; + if (isWrite) + return (ParseCode(response[4], response[5]), response); + + //[4-5] Length : number of data chars, which must all be present before ETX + if (!Tools.IsHex(response[4]) || !Tools.IsHex(response[5])) + return (ResponseCode.WritingError, null); + + var dataLength = Tools.ToByte(response[4], response[5]); + if (response.Length < 6 + dataLength + 1 || response.Skip(6).Take(dataLength).Any(x => !Tools.IsHex(x))) + return (ResponseCode.WritingError, null); + + return (ResponseCode.Success, response); + } + + internal static byte[] BuildFrame(byte command, MemoryType memoryType, string address, int count, IEnumerable payload = null) + { + //[6-n] Data : This is n block and contains up to 3 parts : + // 1- Memory Address : 8 chars, device symbol + 0 + 6 chars address + // 2- Read/Write Size : 2 chars + // 3- Data to write : n chars (write commands only) + var data = new List { memoryType.ToString()[0], '0' }; + data.AddRange(NormalizeAddress(address)); + data.AddRange(((byte)count).ToDualChar()); + if (payload != null) + data.AddRange(payload); + + var frame = new List(data.Count + 9); + + //[0] HEADER : This is 1-byte control letter of ASCII code "ENQ". + frame.Add(SerialFrame.Enq); + + //[1-2] PLC Station Number : multi-drop communication is available if station numbers are assigned frame.Add('0'); frame.Add('0'); //[3] Cmd : In Master, 1 - byte command can be used and the - //format of ‘Data’ field is selected according to each command - frame.Add((char)WriteCommands.BitBlockWrite); + //format of 'Data' field is selected according to each command + frame.Add((char)command); - //[4-5] Length : Total number of the chars of a frame data device - // It contains 8 chars for address + 2 chars word count + 1 char per bit - var length = 10 + data.Length ; - frame.AddRange(((byte)length).ToDualChar()); + //[4-5] Length : Total number of the chars of the data field + frame.AddRange(((byte)data.Count).ToDualChar()); - //[6-15] Data : This is n block based on data needs to write and contains 3 parts : - // 1- [6-13] Memory Address : 8 chars - frame.Add(memoryType.ToString()[0]); - frame.Add('0'); - frame.AddRange(address.ToCharArray()); + frame.AddRange(data); - // 2- [14-15] Read Length : 2 chars - frame.AddRange(((byte)data.Length).ToDualChar()); + //BCC : is the remainder value when dividing the binary-sum from Cmd to the end of data by 256. + frame.AddBCC(); - // 3- [15-n] Words to write : 1 char per bit - foreach (var bit in data) - frame.Add((char)bit); + //End : This is 1-byte control letter of ASCII code "EOT". + frame.Add(SerialFrame.Eot); - // [^3-^2]BCC : 2 chars is the remainder value when dividing the binary-sum from Cmd to the end of data by 256. - frame.AddBCC(); + return frame.Select(Convert.ToByte).ToArray(); + } + + private static char[] ReadData(char[] response) + { + var dataLength = Tools.ToByte(response[4], response[5]); + var data = new char[dataLength]; + Array.Copy(response, 6, data, 0, dataLength); + return data; + } - //[^1] End : This is 1-byte control letter of ASCII code “EOT”. - frame.Add((char)0x4); + private static ResponseCode ParseCode(char char1, char char2) + { + if (!Tools.IsHex(char1) || !Tools.IsHex(char2)) + return ResponseCode.WritingError; - try - { - var sendState = await _socket.SendData(frame.Select(Convert.ToByte).ToArray()); - if (!sendState) - return ResponseCode.WritingError; - - //Read response from PLC - await Task.Delay(_timeout); - var tempFrame = await _socket.ReceiveData(); - if (tempFrame == null || tempFrame == Array.Empty()) - return ResponseCode.SystemError; - var receivedFrame = tempFrame.Select(x => (char)x).ToArray(); - - if (_autoConnect) - _socket.Disconnect(); - - //If response's cmd equals to E(0X45), then must return error code - if (receivedFrame[3] == 'E') - return (ResponseCode)Tools.ToByte(receivedFrame[6], receivedFrame[7]); - - if (!Tools.IsValidSerialResponse(receivedFrame, (byte)WriteCommands.BitBlockWrite)) - return ResponseCode.WritingError; - - return (ResponseCode)Tools.ToByte(receivedFrame[4], receivedFrame[5]); - } - catch (Exception) - { - throw; - } + return (ResponseCode)Tools.ToByte(char1, char2); } } } diff --git a/src/CimonPlc/Public/Guards.cs b/src/CimonPlc/Public/Guards.cs deleted file mode 100644 index f56834d..0000000 --- a/src/CimonPlc/Public/Guards.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text.RegularExpressions; -using JetBrainsNotNullAttribute = JetBrains.Annotations.NotNullAttribute; - -namespace Ardalis.GuardClauses -{ - public static class GuardClauseExtensions - { - public static string BadFormat([JetBrainsNotNull] this IGuardClause guardClause, [JetBrainsNotNull] string input, [JetBrainsNotNull] string parameterName, [JetBrainsNotNull] string regexPattern) - { - if (input != Regex.Match(input, regexPattern).Value) - throw new ArgumentException($"Input {parameterName} was not in required format", parameterName); - - return input; - } - - public static IEnumerable OutOfRange([JetBrainsNotNull] this IGuardClause guardClause, [JetBrainsNotNull] IEnumerable input, [JetBrainsNotNull] string parameterName, T rangeFrom, T rangeTo) where T : IComparable - { - Comparer comparer = Comparer.Default; - - if (comparer.Compare(rangeFrom, rangeTo) >= 0) - { - throw new ArgumentException($"{nameof(rangeFrom)} should be less or equal than {nameof(rangeTo)}"); - } - - if (input.Any(x => comparer.Compare(x, rangeFrom) < 0 || comparer.Compare(x, rangeTo) > 0)) - { - throw new ArgumentOutOfRangeException(parameterName, $"Input {parameterName} was out of range, it must be between {rangeFrom} and {rangeTo}."); - } - - return input; - - } - - public static T InvalidData([JetBrainsNotNull] this IGuardClause guardClause, [JetBrainsNotNull] T input, [JetBrainsNotNull] string parameterName, Func predicate) - { - if (!predicate(input)) - throw new ArgumentException($"Input {parameterName} did not satisfy the options", parameterName); - - return input; - } - } -} diff --git a/src/CimonPlc/Public/Tools.cs b/src/CimonPlc/Public/Tools.cs deleted file mode 100644 index 0974dfc..0000000 --- a/src/CimonPlc/Public/Tools.cs +++ /dev/null @@ -1,105 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.NetworkInformation; -using System.Text; - -namespace CimonPlc -{ - public static class Tools - { - public static IPStatus Ping(IPAddress ip,int pingTimeout=300) - { - try - { - var pingSender = new Ping(); - var reply = pingSender.Send(ip, pingTimeout); - return reply.Status; - } - catch - { - return IPStatus.BadDestination; - } - } - - public static int ToInt(byte byte1, byte byte2) - { - return (byte1 << 8) + byte2; - } - - public static byte ToByte(char char1, char char2) - { - return byte.Parse(string.Concat(char1, char2), System.Globalization.NumberStyles.HexNumber); - } - - public static byte[] ToDualByte(this int number) - { - number &= 0xFFFF; - return new byte[] { (byte)(number >> 8), (byte)(number & 0xFF) }; - } - - public static char[] ToDualChar(this byte number) - { - number &= 0xFF; - return number.ToString("X2").ToArray(); - } - - public static char[] ToQuadChar(this int number) - { - number &= 0xFFFF; - return number.ToString("X4").ToArray(); - } - - public static void AddBCC(this List input) - { - if (input == null || input.Count == 0) - return; - - // Exclude 3 start chars during BCC calculation - var sum = input.Skip(3).Sum(x => Convert.ToByte(x)); - sum %= 256; - - input.AddRange(((byte)sum).ToDualChar()); - } - - public static bool IsValidResponse(byte[] buffer, byte frameNo, int ackCommand) - { - //[0-8] ID : This is a 9 - byte string “ KDT_PLC_S ”. - if (Encoding.UTF8.GetString(buffer, 0, 9) != "KDT_PLC_S") - return false; - - //[9] Frame No : This is 1 - byte data.The value that 128 are added to the number of the - // command frame received from the Master is used - if (buffer[9] - 128 != frameNo) - return false; - - //[10] Cmd :In Slave, 1 - byte command as specified in parameter can be used and the - // format of ‘Data’ field is selected according to command - if (buffer[10] != ackCommand) - return false; - - //Check Sum : This is 2-byte value. After the entire frame is binary-summed by the byte, - //the lower 2 - byte in the result value is used. - var tempArray = new byte[buffer.Length - 2]; - Array.Copy(buffer, 0, tempArray, 0, tempArray.Length); - if (!tempArray.Sum(x => x).ToDualByte().SequenceEqual(new byte[] { buffer[^2], buffer[^1] })) - return false; - - return true; - - } - - internal static bool IsValidSerialResponse(char[] receivedFrame, byte ackCommand) - { - if (receivedFrame[0]!=(char)2 || receivedFrame[^1] != (char)3) - return false; - - //[3] Cmd :In Slave, 1 - byte command must be equal to sent command - if (receivedFrame[3] != (char)ackCommand) - return false; - - return true; - } - } -} diff --git a/src/CimonPlc/Sockets/SerialSocket.cs b/src/CimonPlc/Sockets/SerialSocket.cs index 542a4ee..006d0e3 100644 --- a/src/CimonPlc/Sockets/SerialSocket.cs +++ b/src/CimonPlc/Sockets/SerialSocket.cs @@ -1,52 +1,95 @@ -using Ardalis.GuardClauses; using CimonPlc.Enums; using CimonPlc.Interfaces; +using System; +using System.Collections.Generic; +using System.Diagnostics; using System.IO.Ports; using System.Threading.Tasks; namespace CimonPlc.Sockets { - public class SerialSocket : ISerialSocket + public class SerialSocket : ISerialSocket, IDisposable { - private readonly SerialPort _socket; + private const int PollInterval = 5; - public bool IsConnected => _socket.IsOpen; + private readonly SerialPort _port; + + public bool IsConnected => _port.IsOpen; public SerialSocket(string portName, int baudRate = 9600) { Guard.Against.NullOrEmpty(portName, nameof(portName)); Guard.Against.OutOfRange(baudRate, nameof(baudRate), 75, 256000); - _socket = new SerialPort(portName, baudRate); + _port = new SerialPort(portName, baudRate); } public Task Connect(int readTimeout = 1000, int writeTimeout = 1000, int pingTimeout = 3000) { - _socket.ReadTimeout = readTimeout; - _socket.WriteTimeout = writeTimeout; + _port.ReadTimeout = readTimeout; + _port.WriteTimeout = writeTimeout; + + if (!_port.IsOpen) + _port.Open(); - _socket.Open(); - return Task.FromResult(_socket.IsOpen ? ConnectionStatus.Connected : ConnectionStatus.DisConnected); + return Task.FromResult(_port.IsOpen ? ConnectionStatus.Connected : ConnectionStatus.DisConnected); } public ConnectionStatus Disconnect() { - _socket.Close(); + if (_port.IsOpen) + _port.Close(); + return ConnectionStatus.DisConnected; } public Task SendData(byte[] frame) { - _socket.Write(frame,0,frame.Length); + if (!_port.IsOpen) + return Task.FromResult(false); + + _port.DiscardInBuffer(); + _port.Write(frame, 0, frame.Length); return Task.FromResult(true); } - public Task ReceiveData() + /// + /// Waits for a complete response frame (terminated by ETX) from PLC, up to the read timeout. + /// + /// The received frame, or null if nothing was received + public async Task ReceiveData() + { + if (!_port.IsOpen) + return null; + + var frame = new List(); + var stopwatch = Stopwatch.StartNew(); + + while (stopwatch.ElapsedMilliseconds < _port.ReadTimeout) + { + var available = _port.BytesToRead; + if (available > 0) + { + var buffer = new byte[available]; + var received = _port.Read(buffer, 0, available); + for (var i = 0; i < received; i++) + frame.Add(buffer[i]); + + if (frame[frame.Count - 1] == SerialFrame.Etx) + break; + + continue; + } + + await Task.Delay(PollInterval).ConfigureAwait(false); + } + + return frame.Count == 0 ? null : frame.ToArray(); + } + + public void Dispose() { - var frameLength = _socket.BytesToRead; - var frame = new byte[frameLength]; - var result = _socket.Read(frame,0, frameLength); - return result == frameLength ? Task.FromResult(frame) : null; + _port.Dispose(); } } } diff --git a/src/CimonPlc/Sockets/TcpSocket.cs b/src/CimonPlc/Sockets/TcpSocket.cs index 91a8516..4ba6191 100644 --- a/src/CimonPlc/Sockets/TcpSocket.cs +++ b/src/CimonPlc/Sockets/TcpSocket.cs @@ -1,6 +1,8 @@ -using Ardalis.GuardClauses; using CimonPlc.Enums; using CimonPlc.Interfaces; +using System; +using System.Collections.Generic; +using System.Diagnostics; using System.Net; using System.Net.NetworkInformation; using System.Net.Sockets; @@ -8,58 +10,145 @@ namespace CimonPlc.Sockets { - public class TcpSocket : IEthernetSocket + public class TcpSocket : IEthernetSocket, IDisposable { - private readonly Socket _socket; + private const int PollInterval = 5; + private readonly IPAddress _ip; private readonly int _port; + private readonly bool _usePing; + private Socket _socket; + private int _readTimeout = 1000; - public bool IsConnected => _socket.Connected; + public bool IsConnected => _socket?.Connected == true; - public TcpSocket(string ip, int port = 10620) + /// IP address of the PLC + /// TCP port of the PLC, default is 10620 + /// + /// If true, the PLC is pinged before connecting and + /// is returned when it does not answer. Disable it on networks that block ICMP. + /// + public TcpSocket(string ip, int port = 10620, bool usePing = true) { Guard.Against.OutOfRange(port, nameof(port), 1, 65535); Guard.Against.NullOrEmpty(ip, nameof(ip)); - Guard.Against.InvalidData(ip, nameof(ip), x => IPAddress.TryParse(x, out IPAddress tempIp)); + Guard.Against.InvalidData(ip, nameof(ip), x => IPAddress.TryParse(x, out _)); - _socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); _ip = IPAddress.Parse(ip); _port = port; + _usePing = usePing; } public async Task Connect(int readTimeout = 1000, int writeTimeout = 1000, int pingTimeout = 3000) { - if (Tools.Ping(_ip, pingTimeout) != IPStatus.Success) - { + if (IsConnected) + return ConnectionStatus.Connected; + + if (_usePing && Tools.Ping(_ip, pingTimeout) != IPStatus.Success) return ConnectionStatus.NoRouteToDestination; - } - var remoteEp = new IPEndPoint(_ip, _port); - _socket.ReceiveTimeout = readTimeout; - _socket.SendTimeout = writeTimeout; + // A closed socket can't be reused, so a new one is created for every connection. + CloseSocket(); + _readTimeout = readTimeout; + _socket = new Socket(_ip.AddressFamily, SocketType.Stream, ProtocolType.Tcp) + { + ReceiveTimeout = readTimeout, + SendTimeout = writeTimeout, + NoDelay = true + }; + + try + { + await _socket.ConnectAsync(new IPEndPoint(_ip, _port)).ConfigureAwait(false); + } + catch + { + CloseSocket(); + throw; + } - await _socket.ConnectAsync(remoteEp); return _socket.Connected ? ConnectionStatus.Connected : ConnectionStatus.DisConnected; } public ConnectionStatus Disconnect() { - _socket.Close(); + CloseSocket(); return ConnectionStatus.DisConnected; } public async Task SendData(byte[] frame) { - var result= await _socket.SendAsync(frame, SocketFlags.None); - return (result == frame.Length); + if (!IsConnected) + return false; + + var result = await _socket.SendAsync(new ArraySegment(frame), SocketFlags.None).ConfigureAwait(false); + return result == frame.Length; } + /// + /// Waits for a complete response frame from PLC, up to the read timeout. + /// + /// The received frame, or null if nothing was received public async Task ReceiveData() { - var frameLength = _socket.Available; - var frame = new byte[frameLength]; - var result = await _socket.ReceiveAsync(frame, SocketFlags.None); - return result == frameLength ? frame : null; + if (!IsConnected) + return null; + + var frame = new List(); + var stopwatch = Stopwatch.StartNew(); + + while (stopwatch.ElapsedMilliseconds < _readTimeout) + { + var available = _socket.Available; + if (available > 0) + { + var buffer = new byte[available]; + var received = _socket.Receive(buffer, 0, available, SocketFlags.None); + for (var i = 0; i < received; i++) + frame.Add(buffer[i]); + + var expectedLength = EthernetFrame.ExpectedLength(frame); + if (expectedLength > 0 && frame.Count >= expectedLength) + break; + + continue; + } + + // Readable with nothing to read means the remote side closed the connection. + if (_socket.Poll(0, SelectMode.SelectRead) && _socket.Available == 0) + break; + + await Task.Delay(PollInterval).ConfigureAwait(false); + } + + return frame.Count == 0 ? null : frame.ToArray(); + } + + public void Dispose() + { + CloseSocket(); + } + + private void CloseSocket() + { + var socket = _socket; + _socket = null; + if (socket == null) + return; + + try + { + if (socket.Connected) + socket.Shutdown(SocketShutdown.Both); + } + catch (SocketException) + { + // The connection is already gone + } + finally + { + socket.Dispose(); + } } } } diff --git a/tests/CimonPlc.UnitTests/CimonPlc.UnitTests.csproj b/tests/CimonPlc.UnitTests/CimonPlc.UnitTests.csproj index a67c1fd..a8c99e0 100644 --- a/tests/CimonPlc.UnitTests/CimonPlc.UnitTests.csproj +++ b/tests/CimonPlc.UnitTests/CimonPlc.UnitTests.csproj @@ -1,20 +1,20 @@ - + - netcoreapp3.1 - + net10.0 false + true - + - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/tests/CimonPlc.UnitTests/CimonPlc/EthernetConnectorTests.cs b/tests/CimonPlc.UnitTests/CimonPlc/EthernetConnectorTests.cs index 8370c12..a3d7608 100644 --- a/tests/CimonPlc.UnitTests/CimonPlc/EthernetConnectorTests.cs +++ b/tests/CimonPlc.UnitTests/CimonPlc/EthernetConnectorTests.cs @@ -1,4 +1,5 @@ -using System; +using System; +using System.Threading.Tasks; using Xunit; using CimonPlc.PlcConnectors; using CimonPlc.Enums; @@ -19,7 +20,7 @@ public class EthernetConnectorTests [InlineData(1000, 1000, 1000)] [InlineData(5000, 4000, 3000)] [InlineData(1000, 1000, 5000)] - public async void Connect_Should_Return_Connected_On_Valid_Data(int readTimeout, int writeTimeout, int pingTimeout) + public async Task Connect_Should_Return_Connected_On_Valid_Data(int readTimeout, int writeTimeout, int pingTimeout) { //Arrange var connector = new EthernetConnector(new TcpSocket("127.0.0.1", 10619)); @@ -39,7 +40,7 @@ public async void Connect_Should_Return_Connected_On_Valid_Data(int readTimeout, [InlineData(0, 1000, 1000)] [InlineData(1000, 0, 1000)] [InlineData(1000, 1000, 0)] - public async void Connect_Should_Return_Error_On_Incorrect_Data(int readTimeout, int writeTimeout, int pingTimeout) + public async Task Connect_Should_Return_Error_On_Incorrect_Data(int readTimeout, int writeTimeout, int pingTimeout) { //Arrange var connector = new EthernetConnector(new TcpSocket("127.0.0.1", 10620)); @@ -54,7 +55,7 @@ public async void Connect_Should_Return_Error_On_Incorrect_Data(int readTimeout, [InlineData(MemoryType.D, "000F0", 6)] [InlineData(MemoryType.M, "0F010", 100)] [InlineData(MemoryType.L, "000F10", 100)] - public async void ReadWordAsync_Should_Return_Value_On_Correct_Data(MemoryType memoryType, string address, int length) + public async Task ReadWordAsync_Should_Return_Value_On_Correct_Data(MemoryType memoryType, string address, int length) { //Arrange var connector = new EthernetConnector(new TcpSocket("127.0.0.1", 10620)); @@ -90,7 +91,7 @@ public async void ReadWordAsync_Should_Return_Value_On_Correct_Data(MemoryType m [InlineData(MemoryType.D, "000051", 6)] [InlineData(MemoryType.M, "0F01", 600)] [InlineData(MemoryType.Y, "000F1", 0)] - public async void ReadWordAsync_Should_Return_Error_On_Incorrect_Data(MemoryType memoryType, string address, int length) + public async Task ReadWordAsync_Should_Return_Error_On_Incorrect_Data(MemoryType memoryType, string address, int length) { //Arrange var connector = new EthernetConnector(new TcpSocket("127.0.0.1", 10620)); @@ -105,7 +106,7 @@ public async void ReadWordAsync_Should_Return_Error_On_Incorrect_Data(MemoryType [InlineData(MemoryType.D, "000F5", 6)] [InlineData(MemoryType.M, "0F01", 100)] [InlineData(MemoryType.L, "000F1", 100)] - public async void ReadBitAsync_Should_Return_Value_On_Correct_Data(MemoryType memoryType, string address, int length) + public async Task ReadBitAsync_Should_Return_Value_On_Correct_Data(MemoryType memoryType, string address, int length) { //Arrange var connector = new EthernetConnector(new TcpSocket("127.0.0.1", 10621)); @@ -140,7 +141,7 @@ public async void ReadBitAsync_Should_Return_Value_On_Correct_Data(MemoryType me [InlineData(MemoryType.D, "000x5", 6)] [InlineData(MemoryType.M, "0F01", 1060)] [InlineData(MemoryType.Y, "000F1", 0)] - public async void ReadBitAsync_Should_Return_Error_On_Incorrect_Data(MemoryType memoryType, string address, int length) + public async Task ReadBitAsync_Should_Return_Error_On_Incorrect_Data(MemoryType memoryType, string address, int length) { //Arrange var connector = new EthernetConnector(new TcpSocket("127.0.0.1", 10620)); @@ -155,7 +156,7 @@ public async void ReadBitAsync_Should_Return_Error_On_Incorrect_Data(MemoryType [InlineData(MemoryType.D, "000F0", 16050)] [InlineData(MemoryType.M, "0F010", 100, 100, 10000, 1200, 1400)] [InlineData(MemoryType.L, "000F0", 1010, 65000, 3403, 2302)] - public async void WriteWordAsync_Should_Return_Value_On_Correct_Data(MemoryType memoryType, string address, params int[] data) + public async Task WriteWordAsync_Should_Return_Value_On_Correct_Data(MemoryType memoryType, string address, params int[] data) { //Arrange var connector = new EthernetConnector(new TcpSocket("127.0.0.1", 10622)); @@ -187,7 +188,7 @@ public async void WriteWordAsync_Should_Return_Value_On_Correct_Data(MemoryType [InlineData(MemoryType.M, "0F00", 70000, 30000, 40000)] [InlineData(MemoryType.Y, "000F0", -10, 100, 1000)] [InlineData(MemoryType.Y, "0")] - public async void WriteWordAsync_Should_Return_Error_On_Incorrect_Data(MemoryType memoryType, string address, params int[] data) + public async Task WriteWordAsync_Should_Return_Error_On_Incorrect_Data(MemoryType memoryType, string address, params int[] data) { //Arrange var connector = new EthernetConnector(new TcpSocket("127.0.0.1", 10620)); @@ -203,7 +204,7 @@ public async void WriteWordAsync_Should_Return_Error_On_Incorrect_Data(MemoryTyp [InlineData(MemoryType.D, "000F1", (byte)1, (byte)1)] [InlineData(MemoryType.M, "0F011", (byte)1, (byte)1, (byte)0, (byte)1, (byte)1)] [InlineData(MemoryType.L, "000F0", (byte)1, (byte)1, (byte)1, (byte)0)] - public async void WriteBitAsync_Should_Return_Value_On_Correct_Data(MemoryType memoryType, string address, params byte[] data) + public async Task WriteBitAsync_Should_Return_Value_On_Correct_Data(MemoryType memoryType, string address, params byte[] data) { //Arrange var connector = new EthernetConnector(new TcpSocket("127.0.0.1", 10623)); @@ -234,7 +235,7 @@ public async void WriteBitAsync_Should_Return_Value_On_Correct_Data(MemoryType m [InlineData(MemoryType.D, "000x5", (byte)1)] [InlineData(MemoryType.D, "000F5", (byte)2)] [InlineData(MemoryType.Y, "0")] - public async void WriteBitAsync_Should_Return_Error_On_Incorrect_Data(MemoryType memoryType, string address, params byte[] data) + public async Task WriteBitAsync_Should_Return_Error_On_Incorrect_Data(MemoryType memoryType, string address, params byte[] data) { //Arrange var connector = new EthernetConnector(new TcpSocket("127.0.0.1", 10623)); diff --git a/tests/CimonPlc.UnitTests/CimonPlc/EthernetProtocolTests.cs b/tests/CimonPlc.UnitTests/CimonPlc/EthernetProtocolTests.cs new file mode 100644 index 0000000..94830c4 --- /dev/null +++ b/tests/CimonPlc.UnitTests/CimonPlc/EthernetProtocolTests.cs @@ -0,0 +1,194 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using CimonPlc.Enums; +using CimonPlc.PlcConnectors; +using CimonPlc.Sockets; +using CimonPlc.UnitTests.FakeClasses; +using Rony.Listeners; +using Rony.Net; +using Xunit; + +namespace CimonPlc.UnitTests +{ + public class EthernetProtocolTests + { + private static byte[] WriteAck(byte[] request, int code = 0) + { + var response = new List(); + response.AddRange(Encoding.ASCII.GetBytes("KDT_PLC_S")); + response.Add((byte)(request[9] + 128)); + response.Add(0x41); + response.Add(0); + response.AddRange(new byte[] { 0, 2 }); + response.AddRange(code.ToDualByte()); + response.AddRange(response.Sum(x => x).ToDualByte()); + return response.ToArray(); + } + + [Fact] + public void FrameNo_Should_Cycle_From_0_To_127() + { + //Arrange + var connector = new EthernetConnector(new FakeEthernetSocket(x => WriteAck(x))); + + //Act + var frameNumbers = Enumerable.Range(0, 300).Select(_ => (int)connector.FrameNo).ToArray(); + + //Assert + var expected = Enumerable.Range(0, 300).Select(x => x % 128).ToArray(); + Assert.Equal(expected, frameNumbers); + } + + [Fact] + public void BuildFrame_Should_Use_Two_Bytes_Length_For_Read_Requests() + { + //Act + var frame = EthernetConnector.BuildFrame(5, (byte)ReadCommand.WordBlockRead, MemoryType.D, "100", 3); + + //Assert + var expected = new List(); + expected.AddRange(Encoding.ASCII.GetBytes("KDT_PLC_M")); + expected.AddRange(new byte[] { 5, 0x52, 0, 0, 10, (byte)MemoryType.D, 0 }); + expected.AddRange(Encoding.ASCII.GetBytes("000100")); + expected.AddRange(new byte[] { 0, 3 }); + expected.AddRange(expected.Sum(x => x).ToDualByte()); + Assert.Equal(expected.ToArray(), frame); + } + + [Fact] + public void BuildFrame_Should_Append_Word_Data_For_Write_Requests() + { + //Act + var frame = EthernetConnector.BuildFrame(0, (byte)WriteCommands.WordBlockWrite, MemoryType.D, "0", 2, new byte[] { 0x12, 0x34, 0xAB, 0xCD }); + + //Assert + Assert.Equal(14 + 10 + 4 + 2, frame.Length); + Assert.Equal(new byte[] { 0, 14 }, frame.Skip(12).Take(2)); + Assert.Equal(new byte[] { 0x12, 0x34, 0xAB, 0xCD }, frame.Skip(24).Take(4)); + } + + [Fact] + public async Task WriteWordAsync_Should_Return_PLC_Error_Code() + { + //Arrange + var connector = new EthernetConnector(new FakeEthernetSocket(x => WriteAck(x, (int)ResponseCode.InvalidDeviceAddress))); + + //Act + var result = await connector.WriteWordAsync(MemoryType.D, "0", 1); + + //Assert + Assert.Equal(ResponseCode.InvalidDeviceAddress, result); + } + + [Fact] + public async Task ReadWordAsync_Should_Return_Error_On_Truncated_Response() + { + //Arrange + var connector = new EthernetConnector(new FakeEthernetSocket(x => x.Take(10).ToArray())); + + //Act + var (responseCode, data) = await connector.ReadWordAsync(MemoryType.D, "0", 2); + + //Assert + Assert.Equal(ResponseCode.WritingError, responseCode); + Assert.Null(data); + } + + [Fact] + public async Task ReadWordAsync_Should_Return_Error_On_Wrong_Checksum() + { + //Arrange + var connector = new EthernetConnector(new FakeEthernetSocket(x => + { + var response = WriteAck(x); + response[response.Length - 1]++; + return response; + })); + + //Act + var result = await connector.WriteWordAsync(MemoryType.D, "0", 1); + + //Assert + Assert.Equal(ResponseCode.WritingError, result); + } + + [Fact] + public async Task ReadWordAsync_Should_Return_SystemError_On_No_Response() + { + //Arrange + var connector = new EthernetConnector(new FakeEthernetSocket(x => null)); + + //Act + var (responseCode, data) = await connector.ReadWordAsync(MemoryType.D, "0", 2); + + //Assert + Assert.Equal(ResponseCode.SystemError, responseCode); + Assert.Null(data); + } + + [Fact] + public async Task Operations_Should_Return_SystemError_When_Not_Connected_Without_AutoConnect() + { + //Arrange + var connector = new EthernetConnector(new FakeEthernetSocket(x => WriteAck(x)), autoConnect: false); + + //Act + var result = await connector.WriteWordAsync(MemoryType.D, "0", 1); + + //Assert + Assert.Equal(ResponseCode.SystemError, result); + } + + [Fact] + public async Task AutoConnect_Should_Keep_Explicitly_Opened_Connection() + { + //Arrange + var socket = new FakeEthernetSocket(x => WriteAck(x)); + var connector = new EthernetConnector(socket); + await connector.Connect(); + + //Act + await connector.WriteWordAsync(MemoryType.D, "0", 1); + await connector.WriteWordAsync(MemoryType.D, "0", 2); + + //Assert + Assert.True(connector.IsConnected); + Assert.Equal(1, socket.ConnectCount); + } + + [Fact] + public async Task AutoConnect_Should_Close_Automatically_Opened_Connection() + { + //Arrange + var socket = new FakeEthernetSocket(x => WriteAck(x)); + var connector = new EthernetConnector(socket); + + //Act + await connector.WriteWordAsync(MemoryType.D, "0", 1); + + //Assert + Assert.False(connector.IsConnected); + } + + [Fact] + public async Task TcpSocket_Should_Reconnect_After_AutoDisconnect() + { + //Arrange + using var connector = new EthernetConnector(new TcpSocket("127.0.0.1", 10624)); + using var mockServer = new MockServer(new TcpServer(10624)); + mockServer.Start(); + mockServer.Mock.Send("").Receive(x => WriteAck(x)); + + //Act + var first = await connector.WriteWordAsync(MemoryType.D, "0", 1); + var second = await connector.WriteWordAsync(MemoryType.D, "0", 2); + mockServer.Stop(); + + //Assert + Assert.Equal(ResponseCode.Success, first); + Assert.Equal(ResponseCode.Success, second); + } + } +} diff --git a/tests/CimonPlc.UnitTests/CimonPlc/SerialConnectorTests.cs b/tests/CimonPlc.UnitTests/CimonPlc/SerialConnectorTests.cs index 0eb3f21..0bd86a5 100644 --- a/tests/CimonPlc.UnitTests/CimonPlc/SerialConnectorTests.cs +++ b/tests/CimonPlc.UnitTests/CimonPlc/SerialConnectorTests.cs @@ -1,4 +1,7 @@ -using System; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; using Xunit; using CimonPlc.PlcConnectors; using CimonPlc.Enums; @@ -22,7 +25,7 @@ public SerialConnectorTests() [InlineData(1000, 1000, 1000)] [InlineData(5000, 4000, 3000)] [InlineData(1000, 1000, 5000)] - public async void Connect_Should_Return_Connected_On_Valid_Data(int readTimeout, int writeTimeout, int pingTimeout) + public async Task Connect_Should_Return_Connected_On_Valid_Data(int readTimeout, int writeTimeout, int pingTimeout) { //Act var result = await _connector.Connect(readTimeout, writeTimeout, pingTimeout); @@ -37,7 +40,7 @@ public async void Connect_Should_Return_Connected_On_Valid_Data(int readTimeout, [InlineData(0, 1000, 1000)] [InlineData(1000, 0, 1000)] [InlineData(1000, 1000, 0)] - public async void Connect_Should_Return_Error_On_Incorrect_Data(int readTimeout, int writeTimeout, int pingTimeout) + public async Task Connect_Should_Return_Error_On_Incorrect_Data(int readTimeout, int writeTimeout, int pingTimeout) { //Assert await Assert.ThrowsAnyAsync(() => _connector.Connect(readTimeout, writeTimeout, pingTimeout)); @@ -49,7 +52,7 @@ public async void Connect_Should_Return_Error_On_Incorrect_Data(int readTimeout, [InlineData(MemoryType.D, "000F0", 6)] [InlineData(MemoryType.M, "0F010", 50)] [InlineData(MemoryType.L, "000F10", 30)] - public async void ReadWordAsync_Should_Return_Value_On_Correct_Data(MemoryType memoryType, string address, int length) + public async Task ReadWordAsync_Should_Return_Value_On_Correct_Data(MemoryType memoryType, string address, int length) { //Act var (responseCode, data) = await _connector.ReadWordAsync(memoryType, address, length); @@ -65,7 +68,7 @@ public async void ReadWordAsync_Should_Return_Value_On_Correct_Data(MemoryType m [InlineData(MemoryType.D, "000051", 6)] [InlineData(MemoryType.M, "0F01", 65)] [InlineData(MemoryType.Y, "000F1", 0)] - public async void ReadWordAsync_Should_Return_Error_On_Incorrect_Data(MemoryType memoryType, string address, int length) + public async Task ReadWordAsync_Should_Return_Error_On_Incorrect_Data(MemoryType memoryType, string address, int length) { //Assert await Assert.ThrowsAnyAsync(() => _connector.ReadWordAsync(memoryType, address, length)); @@ -78,7 +81,7 @@ public async void ReadWordAsync_Should_Return_Error_On_Incorrect_Data(MemoryType [InlineData(MemoryType.D, "000F5", 6)] [InlineData(MemoryType.M, "0F01", 126)] [InlineData(MemoryType.L, "000F1", 100)] - public async void ReadBitAsync_Should_Return_Value_On_Correct_Data(MemoryType memoryType, string address, int length) + public async Task ReadBitAsync_Should_Return_Value_On_Correct_Data(MemoryType memoryType, string address, int length) { //Act var (responseCode, data) = await _connector.ReadBitAsync(memoryType, address, length); @@ -93,7 +96,7 @@ public async void ReadBitAsync_Should_Return_Value_On_Correct_Data(MemoryType me [InlineData(MemoryType.D, "000x5", 6)] [InlineData(MemoryType.M, "0F01", 256)] [InlineData(MemoryType.Y, "000F1", 0)] - public async void ReadBitAsync_Should_Return_Error_On_Incorrect_Data(MemoryType memoryType, string address, int length) + public async Task ReadBitAsync_Should_Return_Error_On_Incorrect_Data(MemoryType memoryType, string address, int length) { //Assert await Assert.ThrowsAnyAsync(() => _connector.ReadBitAsync(memoryType, address, length)); @@ -105,7 +108,7 @@ public async void ReadBitAsync_Should_Return_Error_On_Incorrect_Data(MemoryType [InlineData(MemoryType.D, "000F0", 16050)] [InlineData(MemoryType.M, "0F010", 100, 100, 10000, 1200, 1400)] [InlineData(MemoryType.L, "000F0", 1010, 65000, 3403, 2302)] - public async void WriteWordAsync_Should_Return_Value_On_Correct_Data(MemoryType memoryType, string address, params int[] data) + public async Task WriteWordAsync_Should_Return_Value_On_Correct_Data(MemoryType memoryType, string address, params int[] data) { //Act var result = await _connector.WriteWordAsync(memoryType, address, data); @@ -120,7 +123,7 @@ public async void WriteWordAsync_Should_Return_Value_On_Correct_Data(MemoryType [InlineData(MemoryType.M, "0F00", 70000, 30000, 40000)] [InlineData(MemoryType.Y, "000F0", -10, 100, 1000)] [InlineData(MemoryType.Y, "0")] - public async void WriteWordAsync_Should_Return_Error_On_Incorrect_Data(MemoryType memoryType, string address, params int[] data) + public async Task WriteWordAsync_Should_Return_Error_On_Incorrect_Data(MemoryType memoryType, string address, params int[] data) { //Assert await Assert.ThrowsAnyAsync(() => _connector.WriteWordAsync(memoryType, address, data)); @@ -133,7 +136,7 @@ public async void WriteWordAsync_Should_Return_Error_On_Incorrect_Data(MemoryTyp [InlineData(MemoryType.D, "000F1", (byte)1, (byte)1)] [InlineData(MemoryType.M, "0F011", (byte)1, (byte)1, (byte)1, (byte)0, (byte)1)] [InlineData(MemoryType.L, "000F0", (byte)1, (byte)1, (byte)1, (byte)1)] - public async void WriteBitAsync_Should_Return_Value_On_Correct_Data(MemoryType memoryType, string address, params byte[] data) + public async Task WriteBitAsync_Should_Return_Value_On_Correct_Data(MemoryType memoryType, string address, params byte[] data) { //Act var result = await _connector.WriteBitAsync(memoryType, address, data); @@ -147,10 +150,45 @@ public async void WriteBitAsync_Should_Return_Value_On_Correct_Data(MemoryType m [InlineData(MemoryType.D, "000x5", (byte)1)] [InlineData(MemoryType.D, "000F5", (byte)2)] [InlineData(MemoryType.Y, "0")] - public async void WriteBitAsync_Should_Return_Error_On_Incorrect_Data(MemoryType memoryType, string address, params byte[] data) + public async Task WriteBitAsync_Should_Return_Error_On_Incorrect_Data(MemoryType memoryType, string address, params byte[] data) { //Assert await Assert.ThrowsAnyAsync(() => _connector.WriteBitAsync(memoryType, address, data)); } + + + [Fact] + public async Task WriteWordAsync_Should_Reject_More_Words_Than_Frame_Length_Can_Hold() + { + //Arrange + var data = new int[62]; + + //Assert + await Assert.ThrowsAnyAsync(() => _connector.WriteWordAsync(MemoryType.D, "0", data)); + } + + [Fact] + public async Task WriteWordAsync_Should_Accept_Maximum_Word_Count() + { + //Act + var result = await _connector.WriteWordAsync(MemoryType.D, "0", new int[61]); + + //Assert + Assert.Equal(ResponseCode.Success, result); + } + + [Fact] + public void BuildFrame_Should_Create_Read_Request() + { + //Act + var frame = SerialConnector.BuildFrame((byte)ReadCommand.WordBlockRead, MemoryType.D, "a0", 16); + + //Assert + var expected = new List(); + expected.AddRange("\u000500R0AD00000A010"); + expected.AddBCC(); + expected.Add((char)4); + Assert.Equal(expected.Select(Convert.ToByte).ToArray(), frame); + } } } diff --git a/tests/CimonPlc.UnitTests/FakeClasses/FakeEthernetSocket.cs b/tests/CimonPlc.UnitTests/FakeClasses/FakeEthernetSocket.cs new file mode 100644 index 0000000..357f961 --- /dev/null +++ b/tests/CimonPlc.UnitTests/FakeClasses/FakeEthernetSocket.cs @@ -0,0 +1,51 @@ +using CimonPlc.Enums; +using CimonPlc.Interfaces; +using System; +using System.Threading.Tasks; + +namespace CimonPlc.UnitTests.FakeClasses +{ + /// + /// Ethernet socket which answers every request using the given response factory. + /// + public class FakeEthernetSocket : IEthernetSocket + { + private readonly Func _responseFactory; + private byte[] _lastRequest; + + public FakeEthernetSocket(Func responseFactory) + { + _responseFactory = responseFactory; + } + + public bool IsConnected { get; private set; } + + public int ConnectCount { get; private set; } + + public byte[] LastRequest => _lastRequest; + + public Task Connect(int readTimeout = 1000, int writeTimeout = 1000, int pingTimeout = 3000) + { + ConnectCount++; + IsConnected = true; + return Task.FromResult(ConnectionStatus.Connected); + } + + public ConnectionStatus Disconnect() + { + IsConnected = false; + return ConnectionStatus.DisConnected; + } + + public Task SendData(byte[] frame) + { + _lastRequest = frame; + return Task.FromResult(true); + } + + public Task ReceiveData() + { + return Task.FromResult(_responseFactory(_lastRequest)); + } + } +} diff --git a/tests/CimonPlc.UnitTests/FakeCalsses/FakeSerialSocket.cs b/tests/CimonPlc.UnitTests/FakeClasses/FakeSerialSocket.cs similarity index 98% rename from tests/CimonPlc.UnitTests/FakeCalsses/FakeSerialSocket.cs rename to tests/CimonPlc.UnitTests/FakeClasses/FakeSerialSocket.cs index fc80ce8..addb22e 100644 --- a/tests/CimonPlc.UnitTests/FakeCalsses/FakeSerialSocket.cs +++ b/tests/CimonPlc.UnitTests/FakeClasses/FakeSerialSocket.cs @@ -1,4 +1,4 @@ -using CimonPlc.Enums; +using CimonPlc.Enums; using CimonPlc.Interfaces; using System.Threading.Tasks; using System.Collections.Generic; diff --git a/tests/CimonPlc.UnitTests/GuardClauses/GuardAgainstBadFormatTests.cs b/tests/CimonPlc.UnitTests/GuardClauses/GuardAgainstBadFormatTests.cs index 68f47cd..6e7d60c 100644 --- a/tests/CimonPlc.UnitTests/GuardClauses/GuardAgainstBadFormatTests.cs +++ b/tests/CimonPlc.UnitTests/GuardClauses/GuardAgainstBadFormatTests.cs @@ -1,4 +1,4 @@ -using Ardalis.GuardClauses; +using CimonPlc; using System; using Xunit; diff --git a/tests/CimonPlc.UnitTests/GuardClauses/GuardAgainstByteOutOfRangeTests.cs b/tests/CimonPlc.UnitTests/GuardClauses/GuardAgainstByteOutOfRangeTests.cs index 920fdb8..001fcab 100644 --- a/tests/CimonPlc.UnitTests/GuardClauses/GuardAgainstByteOutOfRangeTests.cs +++ b/tests/CimonPlc.UnitTests/GuardClauses/GuardAgainstByteOutOfRangeTests.cs @@ -1,4 +1,4 @@ -using Ardalis.GuardClauses; +using CimonPlc; using System; using System.Collections; using System.Collections.Generic; diff --git a/tests/CimonPlc.UnitTests/GuardClauses/GuardAgainstIntOutOfRangeTests.cs b/tests/CimonPlc.UnitTests/GuardClauses/GuardAgainstIntOutOfRangeTests.cs index 9aba3de..088086d 100644 --- a/tests/CimonPlc.UnitTests/GuardClauses/GuardAgainstIntOutOfRangeTests.cs +++ b/tests/CimonPlc.UnitTests/GuardClauses/GuardAgainstIntOutOfRangeTests.cs @@ -1,4 +1,4 @@ -using Ardalis.GuardClauses; +using CimonPlc; using System; using System.Collections; using System.Collections.Generic; diff --git a/tests/CimonPlc.UnitTests/PublicFunctions/AddBCCTests.cs b/tests/CimonPlc.UnitTests/PublicFunctions/AddBCCTests.cs index f61505f..e95f062 100644 --- a/tests/CimonPlc.UnitTests/PublicFunctions/AddBCCTests.cs +++ b/tests/CimonPlc.UnitTests/PublicFunctions/AddBCCTests.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using Xunit; namespace CimonPlc.UnitTests.PublicFunctions diff --git a/tests/CimonPlc.UnitTests/PublicFunctions/ToDualCharTests.cs b/tests/CimonPlc.UnitTests/PublicFunctions/ToDualCharTests.cs index ab95d2e..c22aebc 100644 --- a/tests/CimonPlc.UnitTests/PublicFunctions/ToDualCharTests.cs +++ b/tests/CimonPlc.UnitTests/PublicFunctions/ToDualCharTests.cs @@ -1,4 +1,4 @@ -using Xunit; +using Xunit; namespace CimonPlc.UnitTests.PublicFunctions { diff --git a/tests/CimonPlc.UnitTests/PublicFunctions/ToQuadCharTests.cs b/tests/CimonPlc.UnitTests/PublicFunctions/ToQuadCharTests.cs index 47fe4dc..4478f36 100644 --- a/tests/CimonPlc.UnitTests/PublicFunctions/ToQuadCharTests.cs +++ b/tests/CimonPlc.UnitTests/PublicFunctions/ToQuadCharTests.cs @@ -1,4 +1,4 @@ -using Xunit; +using Xunit; namespace CimonPlc.UnitTests.PublicFunctions {