diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000000..2a76e3d1987 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,74 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +name: Industrial CI +on: [push, pull_request] + +jobs: + industrial_validation: + name: Protocol Implementation Validation + runs-on: ubuntu-latest + strategy: + matrix: + rust: [stable, 1.75.0] + platform: [x86_64, aarch64] + + steps: + - uses: actions/checkout@v4 + + - name: Rust Toolchain + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: ${{ matrix.rust }} + target: ${{ matrix.platform }}-unknown-linux-gnu + override: true + + - name: Cache Dependencies + uses: actions/cache@v3 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + - name: Security Audit + uses: actions-rs/audit-check@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Protocol Tests + uses: actions-rs/cargo@v1 + with: + command: test + args: --all-features --target ${{ matrix.platform }}-unknown-linux-gnu + + industrial_linting: + name: Industrial-Grade Linting + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions-rs/clippy-check@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + args: --all-targets --all-features -- -D warnings + + - uses: actions-rs/format-check@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + args: --all -- --check diff --git a/plc4rs/CONTRIBUTING.md b/plc4rs/CONTRIBUTING.md new file mode 100644 index 00000000000..e8329c297ed --- /dev/null +++ b/plc4rs/CONTRIBUTING.md @@ -0,0 +1,18 @@ + diff --git a/plc4rs/Cargo.lock b/plc4rs/Cargo.lock new file mode 100644 index 00000000000..f668bc1bff9 --- /dev/null +++ b/plc4rs/Cargo.lock @@ -0,0 +1,584 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-trait" +version = "0.1.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d556ec1359574147ec0c4fc5eb525f3f23263a592b1a9c07e0a75b427de55c97" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "backtrace" +version = "0.3.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets", +] + +[[package]] +name = "bitflags" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.170" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875b3680cb2f8f71bdcf9a30f38d48282f5d3c95cbf9b3fa57269bb5d5c06828" + +[[package]] +name = "lock_api" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e" + +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + +[[package]] +name = "parking_lot" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "plc4rs" +version = "0.1.0" +dependencies = [ + "async-trait", + "bytes", + "nom", + "socket2", + "thiserror", + "tokio", + "tokio-test", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "proc-macro2" +version = "1.0.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1f1914ce909e1658d9907913b4b91947430c7d9be598b15a1912935b8c04801" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b8c0c260b63a8219631167be35e6a988e9554dbd323f8bd08439c8ed1302bd1" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +dependencies = [ + "libc", +] + +[[package]] +name = "smallvec" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd" + +[[package]] +name = "socket2" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02e925281e18ffd9d640e234264753c43edc62d64b2d4cf898f1bc5e75f3fc2" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +dependencies = [ + "cfg-if", + "once_cell", +] + +[[package]] +name = "tokio" +version = "1.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d61fa4ffa3de412bfea335c6ecff681de2b609ba3c77ef3e00e521813a9ed9e" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-test" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7" +dependencies = [ + "async-stream", + "bytes", + "futures-core", + "tokio", + "tokio-stream", +] + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" diff --git a/plc4rs/Cargo.toml b/plc4rs/Cargo.toml new file mode 100644 index 00000000000..7a6ac4c2957 --- /dev/null +++ b/plc4rs/Cargo.toml @@ -0,0 +1,40 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +[package] +name = "plc4rs" +version = "0.1.0" +edition = "2021" +authors = ["Apache PLC4X Team "] +description = "Rust implementation of the Apache PLC4X project" +license = "Apache-2.0" +repository = "https://github.com/apache/plc4x" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +tokio = { version = "1.43.0", features = ["full"] } +thiserror = "1.0.69" +tracing = "0.1.41" +bytes = "1.10.0" +nom = "7.1.3" +async-trait = "0.1.77" +socket2 = "0.5.8" + +[dev-dependencies] +tokio-test = "0.4.3" +tracing-subscriber = "0.3.18" diff --git a/plc4rs/README.md b/plc4rs/README.md new file mode 100644 index 00000000000..73f709fac3f --- /dev/null +++ b/plc4rs/README.md @@ -0,0 +1,52 @@ +# PLC4RS - Apache PLC4X for Rust + +This is the Rust implementation of the Apache PLC4X project, providing a library for industrial communication with PLCs. + +## Features + +- Transport layer with TCP and UDP implementations +- S7 protocol implementation +- Async I/O with Tokio +- Memory-safe implementation with no unsafe code + +## Usage + +See the examples directory for usage examples. + +## Building + +### Prerequisites + +- Rust 1.75.0 or newer +- Cargo + +### Building with Cargo + +```bash +cargo build +``` + +### Building with Maven + +```bash +mvn -Pwith-rust clean install +``` + +## License + +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied. See the License for the +specific language governing permissions and limitations +under the License. diff --git a/plc4rs/examples/transport_usage.rs b/plc4rs/examples/transport_usage.rs new file mode 100644 index 00000000000..253348483c5 --- /dev/null +++ b/plc4rs/examples/transport_usage.rs @@ -0,0 +1,70 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +use plc4rs::spi::{ + TcpTransport, UdpTransport, + config::{TransportConfig, TcpConfig}, +}; +use std::time::Duration; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // TCP Example + let tcp_config = TcpConfig { + base: TransportConfig { + connect_timeout: Duration::from_secs(10), + read_timeout: Duration::from_secs(2), + write_timeout: Duration::from_secs(2), + buffer_size: 1024, + }, + no_delay: true, + keep_alive: true, + }; + + let mut tcp = TcpTransport::new_with_config("192.168.1.1".into(), 102, tcp_config); + tcp.connect().await?; + + let data = b"Hello PLC"; + tcp.write(data).await?; + + let mut buffer = vec![0u8; 1024]; + let len = tcp.read(&mut buffer).await?; + println!("TCP Received: {:?}", &buffer[..len]); + + tcp.close().await?; + + // UDP Example + let udp_config = TransportConfig { + connect_timeout: Duration::from_secs(5), + read_timeout: Duration::from_secs(1), + write_timeout: Duration::from_secs(1), + buffer_size: 1024, + }; + + let mut udp = UdpTransport::new_with_config("192.168.1.1".into(), 102, udp_config); + udp.connect().await?; + + udp.write(data).await?; + let len = udp.read(&mut buffer).await?; + println!("UDP Received: {:?}", &buffer[..len]); + + udp.close().await?; + + Ok(()) +} diff --git a/plc4rs/fuzz/Cargo.toml b/plc4rs/fuzz/Cargo.toml new file mode 100644 index 00000000000..8f596af951a --- /dev/null +++ b/plc4rs/fuzz/Cargo.toml @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/plc4rs/fuzz/fuzz_targets/header_parser.rs b/plc4rs/fuzz/fuzz_targets/header_parser.rs new file mode 100644 index 00000000000..bd244d07ab4 --- /dev/null +++ b/plc4rs/fuzz/fuzz_targets/header_parser.rs @@ -0,0 +1,19 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + diff --git a/plc4rs/pom.xml b/plc4rs/pom.xml new file mode 100644 index 00000000000..65139e06b7e --- /dev/null +++ b/plc4rs/pom.xml @@ -0,0 +1,59 @@ + + + + + 4.0.0 + + + org.apache.plc4x + plc4x-parent + 0.13.0-SNAPSHOT + + + plc4rs + pom + + PLC4Rust + Implementation of the protocol adapters for usage as Rust module. + + + + + + org.questdb + rust-maven-plugin + 1.2.0 + + + rust-build-id + + build + + + ${project.basedir} + true + + + + + + + + diff --git a/plc4rs/src/lib.rs b/plc4rs/src/lib.rs new file mode 100644 index 00000000000..21f6a974254 --- /dev/null +++ b/plc4rs/src/lib.rs @@ -0,0 +1,27 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Export modules +pub mod protocols; +pub mod s7; +pub mod spi; +pub mod types; + +// Re-export commonly used types +pub use crate::spi::Transport; diff --git a/plc4rs/src/license_header.txt b/plc4rs/src/license_header.txt new file mode 100644 index 00000000000..7149be379a7 --- /dev/null +++ b/plc4rs/src/license_header.txt @@ -0,0 +1,18 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ diff --git a/plc4rs/src/protocols/mod.rs b/plc4rs/src/protocols/mod.rs new file mode 100644 index 00000000000..93ef908f511 --- /dev/null +++ b/plc4rs/src/protocols/mod.rs @@ -0,0 +1,20 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +pub mod s7; diff --git a/plc4rs/src/protocols/s7/connection.rs b/plc4rs/src/protocols/s7/connection.rs new file mode 100644 index 00000000000..5d8d8f31c9a --- /dev/null +++ b/plc4rs/src/protocols/s7/connection.rs @@ -0,0 +1,121 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +use nom::number::complete::{be_u16, be_u8}; +use nom::IResult; + +#[derive(Debug, Clone)] +pub struct COTPConnectionRequest { + dst_ref: u16, + src_ref: u16, + class: u8, + parameters: Vec, +} + +#[derive(Debug, Clone)] +pub enum ConnectionParameter { + TpduSize(u8), + SrcTsap(u16), + DstTsap(u16), +} + +#[derive(Debug, Clone)] +pub struct COTPConnectionResponse { + dst_ref: u16, + src_ref: u16, + class: u8, + parameters: Vec, +} + +impl COTPConnectionRequest { + pub fn parse(input: &[u8]) -> IResult<&[u8], Self> { + let (input, dst_ref) = be_u16(input)?; + let (input, src_ref) = be_u16(input)?; + let (input, class) = be_u8(input)?; + let (input, parameters) = ConnectionParameter::parse_all(input)?; + + Ok(( + input, + COTPConnectionRequest { + dst_ref, + src_ref, + class, + parameters, + }, + )) + } +} + +impl COTPConnectionResponse { + pub fn parse(input: &[u8]) -> IResult<&[u8], Self> { + let (input, dst_ref) = be_u16(input)?; + let (input, src_ref) = be_u16(input)?; + let (input, class) = be_u8(input)?; + let (input, parameters) = ConnectionParameter::parse_all(input)?; + + Ok(( + input, + COTPConnectionResponse { + dst_ref, + src_ref, + class, + parameters, + }, + )) + } +} + +impl ConnectionParameter { + fn parse_all(input: &[u8]) -> IResult<&[u8], Vec> { + let mut parameters = Vec::new(); + let mut remaining = input; + + while !remaining.is_empty() { + let (input, param) = Self::parse(remaining)?; + parameters.push(param); + remaining = input; + } + + Ok((remaining, parameters)) + } + + fn parse(input: &[u8]) -> IResult<&[u8], Self> { + let (input, param_code) = be_u8(input)?; + let (input, param_length) = be_u8(input)?; + + match param_code { + 0xC0 => { + let (input, size) = be_u8(input)?; + Ok((input, ConnectionParameter::TpduSize(size))) + } + 0xC1 => { + let (input, tsap) = be_u16(input)?; + Ok((input, ConnectionParameter::SrcTsap(tsap))) + } + 0xC2 => { + let (input, tsap) = be_u16(input)?; + Ok((input, ConnectionParameter::DstTsap(tsap))) + } + _ => Err(nom::Err::Error(nom::error::Error::new( + input, + nom::error::ErrorKind::Tag, + ))), + } + } +} diff --git a/plc4rs/src/protocols/s7/error.rs b/plc4rs/src/protocols/s7/error.rs new file mode 100644 index 00000000000..5770c698d40 --- /dev/null +++ b/plc4rs/src/protocols/s7/error.rs @@ -0,0 +1,59 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum S7Error { + #[error("Invalid message type: {0:#04x}")] + InvalidMessageType(u8), + + #[error("Invalid function code: {0:#04x}")] + InvalidFunctionCode(u8), + + #[error("Invalid parameter type: {0:#04x}")] + InvalidParameterType(u8), + + #[error("Invalid protocol ID: expected 0x32, got {0:#04x}")] + InvalidProtocolId(u8), + + #[error("Invalid TPKT version: expected 0x03, got {0:#04x}")] + InvalidTpktVersion(u8), + + #[error("Invalid length: {0}")] + InvalidLength(String), + + #[error("Parse error: {0}")] + ParseError(String), + + #[error("Invalid variable specification: {0:#04x}")] + InvalidVarSpec(u8), + + #[error("Invalid syntax ID: {0:#04x}")] + InvalidSyntaxId(u8), + + #[error("Invalid area: {0:#04x}")] + InvalidArea(u8), + + #[error("Invalid return code: {0:#04x}")] + InvalidReturnCode(u8), + + #[error("Invalid transport size: {0:#04x}")] + InvalidTransportSize(u8), +} diff --git a/plc4rs/src/protocols/s7/mod.rs b/plc4rs/src/protocols/s7/mod.rs new file mode 100644 index 00000000000..42561337a9c --- /dev/null +++ b/plc4rs/src/protocols/s7/mod.rs @@ -0,0 +1,568 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +pub mod connection; +pub mod error; + +use crate::protocols::s7::connection::{COTPConnectionRequest, COTPConnectionResponse}; +use crate::protocols::s7::error::S7Error; +use nom::bytes::complete::take; +use nom::number::complete::{be_u16, be_u32, be_u8}; +use nom::IResult; +use std::convert::TryFrom; + +/// S7 protocol constants +pub const PROTOCOL_ID: u8 = 0x32; +pub const DEFAULT_PDU_SIZE: u16 = 1024; +pub const DEFAULT_MAX_AMQS_CONS: u16 = 8; +pub const DEFAULT_MAX_AMQS_CALLING: u16 = 8; + +/// Message types for S7 protocol +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MessageType { + JobRequest = 0x01, + Ack = 0x02, + AckData = 0x03, + UserData = 0x07, +} + +impl TryFrom for MessageType { + type Error = S7Error; + + fn try_from(value: u8) -> Result { + match value { + 0x01 => Ok(MessageType::JobRequest), + 0x02 => Ok(MessageType::Ack), + 0x03 => Ok(MessageType::AckData), + 0x07 => Ok(MessageType::UserData), + _ => Err(S7Error::InvalidMessageType(value)), + } + } +} + +impl MessageType { + /// Parse a message type from a byte + pub fn from_byte(byte: u8) -> Result { + match byte { + 0x01 => Ok(MessageType::JobRequest), + 0x02 => Ok(MessageType::Ack), + 0x03 => Ok(MessageType::AckData), + 0x07 => Ok(MessageType::UserData), + _ => Err(error::S7Error::InvalidMessageType(byte)), + } + } + + /// Parse a message type from a byte slice + pub fn parse(input: &[u8]) -> IResult<&[u8], Self> { + let (input, byte) = be_u8(input)?; + match Self::from_byte(byte) { + Ok(message_type) => Ok((input, message_type)), + Err(_) => Err(nom::Err::Error(nom::error::Error::new( + input, + nom::error::ErrorKind::Tag, + ))), + } + } +} + +/// Function codes for S7 protocol +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FunctionCode { + Setup = 0xF0, + ReadVar = 0x04, + WriteVar = 0x05, + RequestDownload = 0x1A, + DownloadBlock = 0x1B, + DownloadEnded = 0x1C, + StartUpload = 0x1D, + Upload = 0x1E, + EndUpload = 0x1F, + PlcControl = 0x28, + PlcStop = 0x29, +} + +impl FunctionCode { + /// Parse a function code from a byte + pub fn from_byte(byte: u8) -> Result { + match byte { + 0xF0 => Ok(FunctionCode::Setup), + 0x04 => Ok(FunctionCode::ReadVar), + 0x05 => Ok(FunctionCode::WriteVar), + 0x1A => Ok(FunctionCode::RequestDownload), + 0x1B => Ok(FunctionCode::DownloadBlock), + 0x1C => Ok(FunctionCode::DownloadEnded), + 0x1D => Ok(FunctionCode::StartUpload), + 0x1E => Ok(FunctionCode::Upload), + 0x1F => Ok(FunctionCode::EndUpload), + 0x28 => Ok(FunctionCode::PlcControl), + 0x29 => Ok(FunctionCode::PlcStop), + _ => Err(error::S7Error::InvalidFunctionCode(byte)), + } + } + + /// Parse a function code from a byte slice + pub fn parse(input: &[u8]) -> IResult<&[u8], Self> { + let (input, byte) = be_u8(input)?; + match Self::from_byte(byte) { + Ok(function_code) => Ok((input, function_code)), + Err(_) => Err(nom::Err::Error(nom::error::Error::new( + input, + nom::error::ErrorKind::Tag, + ))), + } + } +} + +/// S7 message header +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct S7Header { + protocol_id: u8, + message_type: MessageType, + reserved: u16, + pdu_reference: u16, + parameter_length: u16, + data_length: u16, +} + +impl S7Header { + pub fn parse(input: &[u8]) -> IResult<&[u8], Self> { + let (input, protocol_id) = be_u8(input)?; + let (input, msg_type) = be_u8(input)?; + let (input, reserved) = be_u16(input)?; + let (input, pdu_reference) = be_u16(input)?; + let (input, parameter_length) = be_u16(input)?; + let (input, data_length) = be_u16(input)?; + + let message_type = MessageType::try_from(msg_type).map_err(|_| { + nom::Err::Error(nom::error::Error::new(input, nom::error::ErrorKind::Tag)) + })?; + + Ok(( + input, + S7Header { + protocol_id, + message_type, + reserved, + pdu_reference, + parameter_length, + data_length, + }, + )) + } +} + +#[derive(Debug, Clone)] +pub struct TPKTPacket { + protocol_id: u8, // Always 0x03 + reserved: u8, // Always 0x00 + length: u16, // Total length including header + payload: COTPPacket, +} + +impl TPKTPacket { + pub fn parse(input: &[u8]) -> IResult<&[u8], Self> { + let (input, protocol_id) = be_u8(input)?; + let (input, reserved) = be_u8(input)?; + let (input, length) = be_u16(input)?; + let (input, payload) = COTPPacket::parse(input)?; + + Ok(( + input, + TPKTPacket { + protocol_id, + reserved, + length, + payload, + }, + )) + } +} + +#[derive(Debug, Clone)] +pub enum COTPPacket { + Data(COTPDataPacket), + ConnectionRequest(COTPConnectionRequest), + ConnectionResponse(COTPConnectionResponse), +} + +impl COTPPacket { + pub fn parse(input: &[u8]) -> IResult<&[u8], Self> { + let (input, header_length) = be_u8(input)?; + let (input, tpdu_code) = be_u8(input)?; + + match tpdu_code { + 0xF0 => { + let (input, packet) = COTPDataPacket::parse(input)?; + Ok((input, COTPPacket::Data(packet))) + } + 0xE0 => { + let (input, packet) = COTPConnectionRequest::parse(input)?; + Ok((input, COTPPacket::ConnectionRequest(packet))) + } + 0xD0 => { + let (input, packet) = COTPConnectionResponse::parse(input)?; + Ok((input, COTPPacket::ConnectionResponse(packet))) + } + _ => Err(nom::Err::Error(nom::error::Error::new( + input, + nom::error::ErrorKind::Tag, + ))), + } + } +} + +#[derive(Debug, Clone)] +pub struct COTPDataPacket { + eot: bool, // End of transmission + tpdu_ref: u8, // Reference number + data: S7Message, // S7 protocol data +} + +impl COTPDataPacket { + pub fn parse(input: &[u8]) -> IResult<&[u8], Self> { + let (input, flags) = be_u8(input)?; + let (input, tpdu_ref) = be_u8(input)?; + let (input, data) = S7Message::parse(input)?; + + Ok(( + input, + COTPDataPacket { + eot: (flags & 0x80) != 0, + tpdu_ref, + data, + }, + )) + } +} + +#[derive(Debug, Clone)] +pub struct S7Message { + header: S7Header, + parameters: Option, + payload: Option, +} + +impl S7Message { + pub fn parse(input: &[u8]) -> IResult<&[u8], Self> { + let (input, header) = S7Header::parse(input)?; + let (input, parameters) = if header.parameter_length > 0 { + let (input, params) = S7Parameters::parse(input, header.parameter_length)?; + (input, Some(params)) + } else { + (input, None) + }; + let (input, payload) = if header.data_length > 0 { + let (input, data) = S7Payload::parse(input, header.data_length)?; + (input, Some(data)) + } else { + (input, None) + }; + + Ok(( + input, + S7Message { + header, + parameters, + payload, + }, + )) + } +} + +#[derive(Debug, Clone, Copy)] +pub enum ParameterType { + Setup = 0xF0, + ReadVar = 0x04, + WriteVar = 0x05, + StartUpload = 0x1D, + Upload = 0x1E, + EndUpload = 0x1F, + // Add more as needed +} + +impl TryFrom for ParameterType { + type Error = S7Error; + + fn try_from(value: u8) -> Result { + match value { + 0xF0 => Ok(ParameterType::Setup), + 0x04 => Ok(ParameterType::ReadVar), + 0x05 => Ok(ParameterType::WriteVar), + 0x1D => Ok(ParameterType::StartUpload), + 0x1E => Ok(ParameterType::Upload), + 0x1F => Ok(ParameterType::EndUpload), + _ => Err(S7Error::InvalidParameterType(value)), + } + } +} + +#[derive(Debug, Clone)] +pub struct S7Parameters { + parameter_type: ParameterType, + items: Vec, +} + +impl S7Parameters { + pub fn parse(input: &[u8], length: u16) -> IResult<&[u8], Self> { + let (input, param_type) = be_u8(input)?; + let parameter_type = ParameterType::try_from(param_type).map_err(|_| { + nom::Err::Error(nom::error::Error::new(input, nom::error::ErrorKind::Tag)) + })?; + + let (input, items) = match parameter_type { + ParameterType::ReadVar | ParameterType::WriteVar => { + let (input, item_count) = be_u8(input)?; + let mut items = Vec::with_capacity(item_count as usize); + let mut remaining = input; + + for _ in 0..item_count { + let (input, item) = ParameterItem::parse(remaining)?; + items.push(item); + remaining = input; + } + (remaining, items) + } + _ => (input, Vec::new()), + }; + + Ok(( + input, + S7Parameters { + parameter_type, + items, + }, + )) + } +} + +#[derive(Debug, Clone)] +pub struct ParameterItem { + var_spec: VarSpec, + addr_length: u8, + syntax_id: SyntaxId, + area: Area, + db_number: u16, + start_addr: u32, + length: u16, +} + +#[derive(Debug, Clone, Copy)] +pub enum VarSpec { + Bit = 0x01, + Byte = 0x02, + Word = 0x04, + DWord = 0x06, + // Add more as needed +} + +impl TryFrom for VarSpec { + type Error = S7Error; + + fn try_from(value: u8) -> Result { + match value { + 0x01 => Ok(VarSpec::Bit), + 0x02 => Ok(VarSpec::Byte), + 0x04 => Ok(VarSpec::Word), + 0x06 => Ok(VarSpec::DWord), + _ => Err(S7Error::InvalidVarSpec(value)), + } + } +} + +#[derive(Debug, Clone, Copy)] +pub enum SyntaxId { + S7Any = 0x10, + // Add more as needed +} + +impl TryFrom for SyntaxId { + type Error = S7Error; + + fn try_from(value: u8) -> Result { + match value { + 0x10 => Ok(SyntaxId::S7Any), + _ => Err(S7Error::InvalidSyntaxId(value)), + } + } +} + +#[derive(Debug, Clone, Copy)] +pub enum Area { + Inputs = 0x81, + Outputs = 0x82, + Flags = 0x83, + DataBlocks = 0x84, + // Add more as needed +} + +impl TryFrom for Area { + type Error = S7Error; + + fn try_from(value: u8) -> Result { + match value { + 0x81 => Ok(Area::Inputs), + 0x82 => Ok(Area::Outputs), + 0x83 => Ok(Area::Flags), + 0x84 => Ok(Area::DataBlocks), + _ => Err(S7Error::InvalidArea(value)), + } + } +} + +impl ParameterItem { + pub fn parse(input: &[u8]) -> IResult<&[u8], Self> { + let (input, var_spec_byte) = be_u8(input)?; + let (input, length) = be_u8(input)?; + let (input, syntax_id_byte) = be_u8(input)?; + let (input, transport_size) = be_u8(input)?; + let (input, db_number) = be_u16(input)?; + let (input, area_byte) = be_u8(input)?; + let (input, start_addr) = be_u32(input)?; + + let var_spec = VarSpec::try_from(var_spec_byte).map_err(|_| { + nom::Err::Error(nom::error::Error::new(input, nom::error::ErrorKind::Tag)) + })?; + + let syntax_id = SyntaxId::try_from(syntax_id_byte).map_err(|_| { + nom::Err::Error(nom::error::Error::new(input, nom::error::ErrorKind::Tag)) + })?; + + let area = Area::try_from(area_byte).map_err(|_| { + nom::Err::Error(nom::error::Error::new(input, nom::error::ErrorKind::Tag)) + })?; + + Ok(( + input, + ParameterItem { + var_spec, + addr_length: length, + syntax_id, + area, + db_number, + start_addr, + length: 0, // TODO: calculate from transport_size + }, + )) + } +} + +// ... more implementations following s7.mspec + +#[derive(Debug, Clone)] +pub struct S7Payload { + items: Vec, +} + +#[derive(Debug, Clone)] +pub struct PayloadItem { + return_code: ReturnCode, + transport_size: TransportSize, + data: Vec, +} + +#[derive(Debug, Clone, Copy)] +pub enum ReturnCode { + Success = 0x00, + HardwareError = 0x01, + AccessError = 0x03, + OutOfRange = 0x05, + NotSupported = 0x06, + // Add more as needed +} + +impl TryFrom for ReturnCode { + type Error = S7Error; + + fn try_from(value: u8) -> Result { + match value { + 0x00 => Ok(ReturnCode::Success), + 0x01 => Ok(ReturnCode::HardwareError), + 0x03 => Ok(ReturnCode::AccessError), + 0x05 => Ok(ReturnCode::OutOfRange), + 0x06 => Ok(ReturnCode::NotSupported), + _ => Err(S7Error::InvalidReturnCode(value)), + } + } +} + +#[derive(Debug, Clone, Copy)] +pub enum TransportSize { + Bit = 0x01, + Byte = 0x02, + Word = 0x04, + DWord = 0x06, + // Add more as needed +} + +impl TryFrom for TransportSize { + type Error = S7Error; + + fn try_from(value: u8) -> Result { + match value { + 0x01 => Ok(TransportSize::Bit), + 0x02 => Ok(TransportSize::Byte), + 0x04 => Ok(TransportSize::Word), + 0x06 => Ok(TransportSize::DWord), + _ => Err(S7Error::InvalidTransportSize(value)), + } + } +} + +impl S7Payload { + pub fn parse(input: &[u8], length: u16) -> IResult<&[u8], Self> { + let mut items = Vec::new(); + let mut remaining = input; + let end_pos = length as usize; + + while remaining.len() > 0 && (input.len() - remaining.len()) < end_pos { + let (input, item) = PayloadItem::parse(remaining)?; + items.push(item); + remaining = input; + } + + Ok((remaining, S7Payload { items })) + } +} + +impl PayloadItem { + pub fn parse(input: &[u8]) -> IResult<&[u8], Self> { + let (input, return_code) = be_u8(input)?; + let (input, transport_size) = be_u8(input)?; + let (input, length) = be_u16(input)?; + let (input, data) = take(length as usize)(input)?; + + let return_code = ReturnCode::try_from(return_code).map_err(|_| { + nom::Err::Error(nom::error::Error::new(input, nom::error::ErrorKind::Tag)) + })?; + + let transport_size = TransportSize::try_from(transport_size).map_err(|_| { + nom::Err::Error(nom::error::Error::new(input, nom::error::ErrorKind::Tag)) + })?; + + Ok(( + input, + PayloadItem { + return_code, + transport_size, + data: data.to_vec(), + }, + )) + } +} diff --git a/plc4rs/src/protocols/s7/tests.rs b/plc4rs/src/protocols/s7/tests.rs new file mode 100644 index 00000000000..b806f048c77 --- /dev/null +++ b/plc4rs/src/protocols/s7/tests.rs @@ -0,0 +1,95 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_tpkt_packet_parse() { + let input = &[ + 0x03, 0x00, 0x00, 0x1A, // TPKT header + 0x02, 0xF0, 0x80, // COTP header + 0x32, 0x01, 0x00, 0x00, // S7 header start + 0x00, 0x01, 0x00, 0x08, 0x00, + 0x00, // S7 header end + // ... payload ... + ]; + + let (remaining, packet) = TPKTPacket::parse(input).unwrap(); + assert_eq!(packet.protocol_id, 0x03); + assert_eq!(packet.length, 26); + assert!(remaining.is_empty()); + } + + #[test] + fn test_s7_message_parse() { + // Add S7 message parsing test + } + + #[test] + fn test_connection_request_parse() { + let input = &[ + 0x00, 0x0C, // Destination Reference + 0x00, 0x10, // Source Reference + 0x00, // Class + 0xC1, 0x02, 0x01, 0x00, // Source TSAP + 0xC2, 0x02, 0x01, 0x02, // Destination TSAP + ]; + + let (remaining, request) = COTPConnectionRequest::parse(input).unwrap(); + assert_eq!(request.dst_ref, 12); + assert_eq!(request.src_ref, 16); + assert_eq!(request.class, 0); + assert!(remaining.is_empty()); + } + + #[test] + fn test_s7_payload_parse() { + let input = &[ + 0x00, // Return code (Success) + 0x04, // Transport size (Word) + 0x00, 0x02, // Length + 0x12, 0x34, // Data + ]; + + let (remaining, payload) = S7Payload::parse(input, 6).unwrap(); + assert_eq!(payload.items.len(), 1); + assert_eq!(payload.items[0].data, vec![0x12, 0x34]); + assert!(remaining.is_empty()); + } + + #[test] + fn test_parameter_item_parse() { + let input = &[ + 0x02, // VarSpec (Byte) + 0x0A, // Length + 0x10, // Syntax ID (S7Any) + 0x02, // Transport size + 0x00, 0x01, // DB number + 0x84, // Area (DataBlocks) + 0x00, 0x00, 0x00, 0x00, // Start address + ]; + + let (remaining, item) = ParameterItem::parse(input).unwrap(); + assert_eq!(item.addr_length, 10); + assert_eq!(item.db_number, 1); + assert!(remaining.is_empty()); + } +} diff --git a/plc4rs/src/s7.rs b/plc4rs/src/s7.rs new file mode 100644 index 00000000000..f1dfbc64848 --- /dev/null +++ b/plc4rs/src/s7.rs @@ -0,0 +1,40 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + + +/// Message types for S7 protocol +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MessageType { + JobRequest = 0x01, + Ack = 0x02, + AckData = 0x03, + UserData = 0x07, +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct S7Header { + protocol_id: u8, + message_type: MessageType, + reserved: u16, + pdu_reference: u16, + parameter_length: u16, + data_length: u16, +} + diff --git a/plc4rs/src/spi/config.rs b/plc4rs/src/spi/config.rs new file mode 100644 index 00000000000..6834cc81d04 --- /dev/null +++ b/plc4rs/src/spi/config.rs @@ -0,0 +1,171 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +use std::time::Duration; + +/// Common trait for all transport configurations +pub trait TransportConfig { + /// Get the host address + fn host(&self) -> &str; + + /// Get the port number + fn port(&self) -> u16; +} + +/// Configuration for TCP transport +#[derive(Debug, Clone)] +pub struct TcpConfig { + pub host: String, + pub port: u16, + pub connect_timeout: Option, + pub read_timeout: Option, + pub write_timeout: Option, + pub nodelay: Option, + pub ttl: Option, + pub retry_count: Option, +} + +impl TcpConfig { + /// Create a new TCP configuration with default settings + pub fn new(host: impl Into, port: u16) -> Self { + Self { + host: host.into(), + port, + connect_timeout: Some(Duration::from_secs(5)), + read_timeout: Some(Duration::from_secs(5)), + write_timeout: Some(Duration::from_secs(5)), + nodelay: Some(true), + ttl: None, + retry_count: Some(3), + } + } + + /// Set the connect timeout + pub fn with_connect_timeout(mut self, timeout: Duration) -> Self { + self.connect_timeout = Some(timeout); + self + } + + /// Set the read timeout + pub fn with_read_timeout(mut self, timeout: Duration) -> Self { + self.read_timeout = Some(timeout); + self + } + + /// Set the write timeout + pub fn with_write_timeout(mut self, timeout: Duration) -> Self { + self.write_timeout = Some(timeout); + self + } + + /// Set the TCP nodelay option + pub fn with_nodelay(mut self, nodelay: bool) -> Self { + self.nodelay = Some(nodelay); + self + } + + /// Set the TTL value + pub fn with_ttl(mut self, ttl: u32) -> Self { + self.ttl = Some(ttl); + self + } + + /// Set the retry count + pub fn with_retry_count(mut self, count: u32) -> Self { + self.retry_count = Some(count); + self + } +} + +impl TransportConfig for TcpConfig { + fn host(&self) -> &str { + &self.host + } + + fn port(&self) -> u16 { + self.port + } +} + +/// Configuration for UDP transport +#[derive(Debug, Clone)] +pub struct UdpConfig { + pub host: String, + pub port: u16, + pub local_port: Option, + pub read_timeout: Option, + pub write_timeout: Option, + pub ttl: Option, + pub broadcast: Option, +} + +impl UdpConfig { + /// Create a new UDP configuration with default settings + pub fn new(host: impl Into, port: u16) -> Self { + Self { + host: host.into(), + port, + local_port: None, + read_timeout: Some(Duration::from_secs(5)), + write_timeout: Some(Duration::from_secs(5)), + ttl: None, + broadcast: None, + } + } + + /// Set the local port to bind to + pub fn with_local_port(mut self, port: u16) -> Self { + self.local_port = Some(port); + self + } + + /// Set the read timeout + pub fn with_read_timeout(mut self, timeout: Duration) -> Self { + self.read_timeout = Some(timeout); + self + } + + /// Set the write timeout + pub fn with_write_timeout(mut self, timeout: Duration) -> Self { + self.write_timeout = Some(timeout); + self + } + + /// Set the TTL value + pub fn with_ttl(mut self, ttl: u32) -> Self { + self.ttl = Some(ttl); + self + } + + /// Set the broadcast option + pub fn with_broadcast(mut self, broadcast: bool) -> Self { + self.broadcast = Some(broadcast); + self + } +} + +impl TransportConfig for UdpConfig { + fn host(&self) -> &str { + &self.host + } + + fn port(&self) -> u16 { + self.port + } +} diff --git a/plc4rs/src/spi/error.rs b/plc4rs/src/spi/error.rs new file mode 100644 index 00000000000..ba8ded59252 --- /dev/null +++ b/plc4rs/src/spi/error.rs @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +use std::io; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum TransportError { + #[error("IO error: {0}")] + Io(#[from] io::Error), + + #[error("Connection error: {0}")] + Connection(String), + + #[error("Not connected")] + NotConnected, + + #[error("Already connected")] + AlreadyConnected, +} diff --git a/plc4rs/src/spi/mod.rs b/plc4rs/src/spi/mod.rs new file mode 100644 index 00000000000..72eacc567e3 --- /dev/null +++ b/plc4rs/src/spi/mod.rs @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +pub mod config; +mod tcp; +mod udp; + +use bytes::BytesMut; +use std::fmt::Debug; +use crate::types::Result; + +// Re-export implementations +pub use tcp::TcpTransport; +pub use udp::UdpTransport; + +/// Transport trait defining the interface for all transport implementations +#[async_trait::async_trait] +pub trait Transport: Debug + Send + Sync { + /// Connect to the target + async fn connect(&mut self) -> Result<()>; + + /// Read data from the transport + async fn read(&mut self, buffer: &mut BytesMut) -> Result; + + /// Write data to the transport + async fn write(&mut self, data: &[u8]) -> Result; + + /// Close the transport connection + async fn close(&mut self) -> Result<()>; +} diff --git a/plc4rs/src/spi/tcp.rs b/plc4rs/src/spi/tcp.rs new file mode 100644 index 00000000000..b29296b1c06 --- /dev/null +++ b/plc4rs/src/spi/tcp.rs @@ -0,0 +1,163 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +use crate::spi::Transport; +use crate::spi::config::TcpConfig; +use crate::types::Result; +use async_trait::async_trait; +use bytes::BytesMut; +use std::time::Duration; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use tokio::time::timeout; +use tracing::{debug, error, info}; + +/// TCP transport implementation +#[derive(Debug)] +pub struct TcpTransport { + config: TcpConfig, + stream: Option, +} + +impl TcpTransport { + /// Create a new TCP transport with the given configuration + pub fn new(config: TcpConfig) -> Self { + Self { + config, + stream: None, + } + } +} + +#[async_trait] +impl Transport for TcpTransport { + async fn connect(&mut self) -> Result<()> { + let address = format!("{}:{}", self.config.host, self.config.port); + info!("Connecting to {}", address); + + let mut retry_count = 0; + let max_retries = self.config.retry_count.unwrap_or(3); + + while retry_count < max_retries { + match timeout( + self.config.connect_timeout.unwrap_or(Duration::from_secs(5)), + TcpStream::connect(&address), + ).await { + Ok(Ok(stream)) => { + // Configure the stream + if let Some(nodelay) = self.config.nodelay { + stream.set_nodelay(nodelay)?; + } + + if let Some(ttl) = self.config.ttl { + stream.set_ttl(ttl)?; + } + + self.stream = Some(stream); + info!("Connected to {}", address); + return Ok(()); + } + Ok(Err(e)) => { + error!("Failed to connect: {}", e); + retry_count += 1; + if retry_count < max_retries { + let backoff = Duration::from_millis(100 * 2u64.pow(retry_count)); + debug!("Retrying in {:?} (attempt {}/{})", backoff, retry_count + 1, max_retries); + tokio::time::sleep(backoff).await; + } + } + Err(_) => { + error!("Connection timed out"); + retry_count += 1; + if retry_count < max_retries { + let backoff = Duration::from_millis(100 * 2u64.pow(retry_count)); + debug!("Retrying in {:?} (attempt {}/{})", backoff, retry_count + 1, max_retries); + tokio::time::sleep(backoff).await; + } + } + } + } + + Err("Failed to connect after maximum retries".into()) + } + + async fn read(&mut self, buffer: &mut BytesMut) -> Result { + if let Some(stream) = &mut self.stream { + let read_timeout = self.config.read_timeout.unwrap_or(Duration::from_secs(5)); + match timeout(read_timeout, stream.read_buf(buffer)).await { + Ok(Ok(n)) => Ok(n), + Ok(Err(e)) => Err(format!("Read error: {}", e).into()), + Err(_) => Err("Read operation timed out".into()), + } + } else { + Err("Not connected".into()) + } + } + + async fn write(&mut self, data: &[u8]) -> Result { + if let Some(stream) = &mut self.stream { + let write_timeout = self.config.write_timeout.unwrap_or(Duration::from_secs(5)); + match timeout(write_timeout, stream.write(data)).await { + Ok(Ok(n)) => Ok(n), + Ok(Err(e)) => Err(format!("Write error: {}", e).into()), + Err(_) => Err("Write operation timed out".into()), + } + } else { + Err("Not connected".into()) + } + } + + async fn close(&mut self) -> Result<()> { + if let Some(stream) = &mut self.stream { + stream.shutdown().await?; + self.stream = None; + info!("Connection closed"); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio_test::block_on; + + #[test] + fn test_tcp_transport() { + let mut transport = TcpTransport::new(TcpConfig { + base: Default::default(), + no_delay: true, + keep_alive: true, + host: "127.0.0.1".to_string(), + port: 102, + retry_count: None, + connect_timeout: None, + read_timeout: None, + write_timeout: None, + nodelay: None, + ttl: None, + }); + + // Test connection + block_on(async { + assert!(transport.connect().await.is_err()); // Should fail as no server is running + assert!(transport.stream.is_none()); + }); + } +} diff --git a/plc4rs/src/spi/udp.rs b/plc4rs/src/spi/udp.rs new file mode 100644 index 00000000000..a0595651cfd --- /dev/null +++ b/plc4rs/src/spi/udp.rs @@ -0,0 +1,162 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +use crate::spi::Transport; +use crate::spi::config::UdpConfig; +use crate::types::Result; +use async_trait::async_trait; +use bytes::BytesMut; +use std::net::SocketAddr; +use std::time::Duration; +use tokio::net::UdpSocket; +use tokio::time::timeout; +use tracing::info; + +/// UDP transport implementation +#[derive(Debug)] +pub struct UdpTransport { + config: UdpConfig, + socket: Option, + remote_addr: Option, +} + +impl UdpTransport { + /// Create a new UDP transport with the given configuration + pub fn new(config: UdpConfig) -> Self { + Self { + config, + socket: None, + remote_addr: None, + } + } +} + +#[async_trait] +impl Transport for UdpTransport { + async fn connect(&mut self) -> Result<()> { + let remote_address = format!("{}:{}", self.config.host, self.config.port); + info!("Connecting to {}", remote_address); + + // Parse the remote address + let remote_addr: SocketAddr = remote_address.parse()?; + self.remote_addr = Some(remote_addr); + + // Bind to a local address + let local_addr: SocketAddr = if let Some(local_port) = self.config.local_port { + format!("0.0.0.0:{}", local_port).parse()? + } else { + "0.0.0.0:0".parse()? + }; + + let socket = UdpSocket::bind(local_addr).await?; + + // Set socket options + if let Some(ttl) = self.config.ttl { + socket.set_ttl(ttl)?; + } + + if let Some(broadcast) = self.config.broadcast { + socket.set_broadcast(broadcast)?; + } + + self.socket = Some(socket); + info!("UDP socket bound to local address"); + + Ok(()) + } + + async fn read(&mut self, buffer: &mut BytesMut) -> Result { + if let Some(socket) = &self.socket { + let read_timeout = self.config.read_timeout.unwrap_or(Duration::from_secs(5)); + + // Ensure we have enough capacity + if buffer.capacity() - buffer.len() < 65536 { + buffer.reserve(65536); + } + + match timeout(read_timeout, socket.recv_buf(buffer)).await { + Ok(Ok(n)) => Ok(n), + Ok(Err(e)) => Err(format!("Read error: {}", e).into()), + Err(_) => Err("Read operation timed out".into()), + } + } else { + Err("Not connected".into()) + } + } + + async fn write(&mut self, data: &[u8]) -> Result { + if let Some(socket) = &self.socket { + if let Some(remote_addr) = self.remote_addr { + let write_timeout = self.config.write_timeout.unwrap_or(Duration::from_secs(5)); + + match timeout(write_timeout, socket.send_to(data, remote_addr)).await { + Ok(Ok(n)) => Ok(n), + Ok(Err(e)) => Err(format!("Write error: {}", e).into()), + Err(_) => Err("Write operation timed out".into()), + } + } else { + Err("Remote address not set".into()) + } + } else { + Err("Not connected".into()) + } + } + + async fn close(&mut self) -> Result<()> { + // UDP sockets don't need explicit closing, but we'll reset our state + self.socket = None; + self.remote_addr = None; + info!("UDP connection closed"); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio_test::block_on; + + #[test] + fn test_udp_lifecycle() { + let mut transport = UdpTransport::new(UdpConfig { + host: "127.0.0.1".to_string(), + port: 1234, + local_port: None, + ttl: None, + broadcast: None, + read_timeout: None, + write_timeout: None, + }); + + block_on(async { + // Test connection + assert!(transport.connect().await.is_ok()); + assert!(transport.socket.is_some()); + + // Test write/read + let data = b"test data"; + let result = transport.write(data).await; + assert!(result.is_ok() || result.is_err()); // May fail as no server + + // Test close + assert!(transport.close().await.is_ok()); + assert!(transport.socket.is_none()); + }); + } +} diff --git a/plc4rs/src/types.rs b/plc4rs/src/types.rs new file mode 100644 index 00000000000..d13d62be372 --- /dev/null +++ b/plc4rs/src/types.rs @@ -0,0 +1,29 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +use std::time::Duration; + +/// Common result type used throughout the library +pub type Result = std::result::Result>; + +/// Default timeout for operations +pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5); + +/// Default retry count for operations +pub const DEFAULT_RETRY_COUNT: u32 = 3; diff --git a/pom.xml b/pom.xml index 0bcdca65258..6ed4e31f74c 100644 --- a/pom.xml +++ b/pom.xml @@ -1779,6 +1779,13 @@ + + + with-rust + + plc4rs + +