├── .cargo └── config.toml ├── .codespellignore ├── .commitlintrc.json ├── .github ├── dependabot.yaml └── workflows │ ├── build-and-test.yaml │ ├── pre-commit.yaml │ └── security-audit.yaml ├── .gitignore ├── .justfile ├── .markdownlint-cli2.yaml ├── .pre-commit-config.yaml ├── .prettierrc.yaml ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── crates ├── msr-core │ ├── Cargo.toml │ ├── src │ │ ├── audit.rs │ │ ├── control │ │ │ ├── cyclic.rs │ │ │ └── mod.rs │ │ ├── event_journal │ │ │ ├── csv.rs │ │ │ └── mod.rs │ │ ├── fs │ │ │ ├── csv │ │ │ │ ├── mod.rs │ │ │ │ └── tests.rs │ │ │ ├── mod.rs │ │ │ └── policy │ │ │ │ ├── mod.rs │ │ │ │ └── tests.rs │ │ ├── io │ │ │ ├── mod.rs │ │ │ └── tests.rs │ │ ├── lib.rs │ │ ├── measure.rs │ │ ├── realtime │ │ │ ├── mod.rs │ │ │ └── worker │ │ │ │ ├── mod.rs │ │ │ │ ├── progress │ │ │ │ ├── mod.rs │ │ │ │ └── tests.rs │ │ │ │ └── thread │ │ │ │ ├── mod.rs │ │ │ │ └── tests.rs │ │ ├── register │ │ │ ├── mod.rs │ │ │ └── recorder │ │ │ │ ├── csv.rs │ │ │ │ └── mod.rs │ │ ├── storage │ │ │ ├── csv.rs │ │ │ ├── field.rs │ │ │ └── mod.rs │ │ ├── sync │ │ │ ├── atomic.rs │ │ │ ├── mod.rs │ │ │ └── relay │ │ │ │ ├── mod.rs │ │ │ │ └── tests.rs │ │ ├── thread.rs │ │ ├── time.rs │ │ └── value │ │ │ ├── mod.rs │ │ │ └── scalar │ │ │ ├── mod.rs │ │ │ └── tests.rs │ └── tests │ │ └── cyclic_realtime_worker_timing.rs ├── msr-legacy │ ├── Cargo.toml │ └── src │ │ ├── bang_bang.rs │ │ ├── comparison.rs │ │ ├── entities.rs │ │ ├── fsm.rs │ │ ├── lib.rs │ │ ├── parser.rs │ │ ├── pid.rs │ │ ├── runtime.rs │ │ ├── util.rs │ │ └── value.rs ├── msr-plugin │ ├── Cargo.toml │ └── src │ │ └── lib.rs └── msr │ ├── Cargo.toml │ ├── examples │ └── modbus-tcp-recording.rs │ └── src │ └── lib.rs ├── doc ├── .gitignore ├── README.md ├── book.toml ├── c4-plantuml │ ├── C4.puml │ ├── C4_Component.puml │ ├── C4_Container.puml │ ├── C4_Context.puml │ ├── C4_Deployment.puml │ └── C4_Dynamic.puml └── src │ ├── SUMMARY.md │ ├── architecture.md │ ├── architecture │ ├── components.md │ ├── components │ │ ├── plugin.md │ │ ├── usecases.md │ │ └── web.md │ ├── context.md │ └── messaging.md │ └── introduction.md └── plugins ├── csv-event-journal ├── Cargo.toml └── src │ ├── api │ ├── command.rs │ ├── controller.rs │ ├── event.rs │ ├── mod.rs │ └── query.rs │ ├── internal │ ├── context.rs │ ├── invoke_context_from_message_loop.rs │ ├── message_loop.rs │ └── mod.rs │ └── lib.rs └── csv-register-recorder ├── Cargo.toml └── src ├── api ├── command.rs ├── controller.rs ├── event.rs ├── mod.rs └── query.rs ├── internal ├── context.rs ├── invoke_context_from_message_loop.rs ├── message_loop.rs ├── mod.rs └── register.rs └── lib.rs /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | # Required for cross-compiling ARMv7 on x86-64 2 | # See also: 3 | [target.armv7-unknown-linux-gnueabihf] 4 | linker = "arm-linux-gnueabihf-gcc" 5 | -------------------------------------------------------------------------------- /.codespellignore: -------------------------------------------------------------------------------- 1 | crate 2 | ro 3 | fo 4 | -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "subject-min-length": [ 4 | 2, 5 | "always", 6 | 8 7 | ], 8 | "subject-max-length": [ 9 | 2, 10 | "always", 11 | 50 12 | ], 13 | "subject-case": [ 14 | 2, 15 | "always", 16 | "sentence-case" 17 | ], 18 | "subject-full-stop": [ 19 | 2, 20 | "never", 21 | "." 22 | ], 23 | "body-max-line-length": [ 24 | 2, 25 | "always", 26 | 72 27 | ], 28 | "footer-leading-blank": [ 29 | 2, 30 | "always" 31 | ] 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | -------------------------------------------------------------------------------- /.github/workflows/build-and-test.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow 2 | 3 | name: build-and-test 4 | 5 | permissions: 6 | contents: read 7 | 8 | on: 9 | pull_request: 10 | push: 11 | branches: 12 | - main 13 | workflow_dispatch: 14 | 15 | env: 16 | CARGO_INCREMENTAL: 0 17 | CARGO_TERM_COLOR: always 18 | RUST_BACKTRACE: short 19 | 20 | jobs: 21 | run: 22 | strategy: 23 | matrix: 24 | include: 25 | - target: aarch64-apple-darwin 26 | runner_os: macos-latest 27 | # Runner (x86-64) and target are not compatible. 28 | run_tests: false 29 | - target: armv7-unknown-linux-gnueabihf 30 | runner_os: ubuntu-latest 31 | # Runner (x86-64) and target are not compatible. 32 | run_tests: false 33 | - target: x86_64-pc-windows-msvc 34 | runner_os: windows-latest 35 | run_tests: true 36 | - target: x86_64-unknown-linux-musl 37 | runner_os: ubuntu-latest 38 | run_tests: true 39 | 40 | runs-on: ${{ matrix.runner_os }} 41 | 42 | steps: 43 | - name: Install build tools for musl libc 44 | if: matrix.target == 'x86_64-unknown-linux-musl' 45 | run: >- 46 | sudo apt update && 47 | sudo apt -y install 48 | musl-tools 49 | 50 | - name: Install build tools for ARMv7 51 | if: matrix.target == 'armv7-unknown-linux-gnueabihf' 52 | run: >- 53 | sudo apt update && 54 | sudo apt -y install 55 | gcc-arm-linux-gnueabihf 56 | 57 | - name: Install Rust toolchain 58 | uses: dtolnay/rust-toolchain@stable 59 | with: 60 | targets: ${{ matrix.target }} 61 | 62 | # Checkout the repository before the remaining steps that depend on it. 63 | # All preceding steps are independent of the repository contents. 64 | - name: Check out repository 65 | uses: actions/checkout@v4 66 | 67 | - name: Generate Cargo.lock 68 | run: cargo generate-lockfile 69 | 70 | - name: Cache Rust toolchain and build artifacts 71 | uses: Swatinem/rust-cache@v2 72 | with: 73 | # The cache should not be shared between different workflows and jobs. 74 | shared-key: ${{ github.workflow }}-${{ github.job }} 75 | # Two jobs might share the same default target but have different build targets. 76 | key: ${{ matrix.target }} 77 | 78 | - name: Build tests with all features enabled 79 | run: >- 80 | cargo test --locked --workspace --all-targets --all-features --target ${{ matrix.target }} 81 | --no-run 82 | 83 | - name: Run tests with all features enabled 84 | if: matrix.run_tests 85 | run: >- 86 | cargo test --locked --workspace --all-targets --all-features --target ${{ matrix.target }} 87 | -- --nocapture --quiet 88 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow 2 | 3 | name: pre-commit 4 | 5 | permissions: 6 | contents: read 7 | 8 | on: 9 | pull_request: 10 | push: 11 | branches: 12 | - "*" 13 | workflow_dispatch: 14 | 15 | env: 16 | CARGO_TERM_COLOR: always 17 | 18 | jobs: 19 | run: 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - name: Setup Python 24 | uses: actions/setup-python@v4 25 | with: 26 | python-version: "3.x" 27 | 28 | - name: Install Rust toolchain 29 | uses: dtolnay/rust-toolchain@stable 30 | with: 31 | components: clippy, rustfmt 32 | 33 | - name: Check out repository 34 | uses: actions/checkout@v4 35 | 36 | - name: Generate Cargo.lock 37 | run: cargo generate-lockfile 38 | 39 | - name: Cache Rust toolchain and build artifacts 40 | uses: Swatinem/rust-cache@v2 41 | with: 42 | # The cache should not be shared between different workflows and jobs. 43 | shared-key: ${{ github.workflow }}-${{ github.job }} 44 | # Two jobs might share the same default target but have different build targets. 45 | key: ${{ matrix.target }} 46 | 47 | - name: Detect code style issues (push) 48 | uses: pre-commit/action@v3.0.0 49 | if: github.event_name == 'push' 50 | 51 | - name: Detect code style issues (pull_request) 52 | uses: pre-commit/action@v3.0.0 53 | if: github.event_name == 'pull_request' 54 | env: 55 | SKIP: no-commit-to-branch 56 | -------------------------------------------------------------------------------- /.github/workflows/security-audit.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow 2 | 3 | name: security-audit 4 | 5 | permissions: 6 | contents: read 7 | 8 | on: 9 | push: 10 | paths: 11 | - "**/Cargo.toml" 12 | schedule: 13 | # Weekly, i.e. on Sunday at 04:42 UTC 14 | - cron: "42 4 * * 0" 15 | workflow_dispatch: 16 | 17 | jobs: 18 | run: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Install Rust toolchain 22 | uses: dtolnay/rust-toolchain@stable 23 | 24 | - name: Install cargo-audit 25 | run: cargo install cargo-audit 26 | 27 | - uses: actions/checkout@v4 28 | 29 | - name: Generate Cargo.lock 30 | run: cargo generate-lockfile 31 | 32 | - name: Cache Rust toolchain and build artifacts 33 | uses: Swatinem/rust-cache@v2 34 | with: 35 | # The cache should not be shared between different workflows and jobs. 36 | shared-key: ${{ github.workflow }}-${{ github.job }} 37 | # Two jobs might share the same default target but have different build targets. 38 | key: ${{ matrix.target }} 39 | 40 | - name: Run security audit 41 | run: cargo audit --deny unsound --deny yanked 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | -------------------------------------------------------------------------------- /.justfile: -------------------------------------------------------------------------------- 1 | # just manual: https://github.com/casey/just/#readme 2 | 3 | _default: 4 | @just --list 5 | 6 | # Format source code 7 | fmt: 8 | cargo fmt --all 9 | 10 | # Run clippy for various feature combinations: default, no default, all 11 | clippy: 12 | cargo clippy --locked --workspace --no-deps --all-targets -- -D warnings 13 | cargo clippy --locked --workspace --no-deps --no-default-features --all-targets -- -D warnings 14 | cargo clippy --locked --workspace --no-deps --all-features --all-targets -- -D warnings 15 | 16 | # Fix lint warnings 17 | fix: 18 | cargo fix --workspace --all-features --all-targets 19 | cargo clippy --workspace --all-features --all-targets --fix 20 | 21 | # Run unit tests for various feature combinations: default, no default, all 22 | test: 23 | cargo test --locked --workspace -- --nocapture --include-ignored 24 | cargo test --locked --workspace --no-default-features -- --nocapture --include-ignored 25 | cargo test --locked --workspace --all-features -- --nocapture --include-ignored 26 | 27 | # Set up (and update) tooling 28 | setup: 29 | # Ignore rustup failures, because not everyone might use it 30 | rustup self update || true 31 | # cargo-edit is needed for `cargo upgrade` 32 | cargo install \ 33 | just \ 34 | cargo-edit \ 35 | cargo-hack 36 | pip install -U pre-commit 37 | #pre-commit install --hook-type commit-msg --hook-type pre-commit 38 | 39 | # Upgrade (and update) dependencies 40 | upgrade: setup 41 | pre-commit autoupdate 42 | cargo upgrade 43 | cargo update 44 | 45 | # Run pre-commit hooks 46 | pre-commit: 47 | pre-commit run --all-files 48 | 49 | # Check all lib/bin projects individually with selected features (takes a long time) 50 | check-crates: 51 | cargo hack --feature-powerset check --locked --all-targets -p msr-core 52 | RUSTFLAGS="--cfg loom" cargo hack --feature-powerset check --locked --all-targets -p msr-core 53 | cargo hack --feature-powerset check --locked --all-targets -p msr-plugin 54 | cargo hack --feature-powerset check --locked --all-targets -p msr-plugin-csv-event-journal 55 | cargo hack --feature-powerset check --locked --all-targets -p msr-plugin-csv-register-recorder 56 | -------------------------------------------------------------------------------- /.markdownlint-cli2.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/markdownlint.json 2 | 3 | # Disable some built-in rules 4 | config: 5 | default: true 6 | 7 | # The same headline in different nested sections is okay (and necessary for 8 | # CHANGELOG.md). 9 | no-duplicate-header: 10 | allow_different_nesting: true 11 | 12 | # We use ordered lists to make stuff easier to read in a text editor. 13 | ol-prefix: 14 | style: ordered 15 | 16 | # Not wrapping long lines makes diffs easier to read, especially for prose. 17 | # Instead, we should follow the "one sentence per line" pattern. 18 | line-length: false 19 | 20 | # Dollar signs are useful to indicate shell commands/type and help 21 | # distinguishing wrapped lines from new commands. 22 | commands-show-output: false 23 | 24 | # Indented code blocks are easier to read in a text editor, but don't allow 25 | # specifying a language for syntax highlighting. Therefore both indented and 26 | # fenced code block should be allowed depending on the use case. 27 | code-block-style: false 28 | 29 | # Fix any fixable errors 30 | fix: true 31 | 32 | # Disable inline config comments 33 | noInlineConfig: true 34 | 35 | # Disable progress on stdout (only valid at root) 36 | noProgress: true 37 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/pre-commit-config.json 2 | 3 | default_stages: 4 | # Prevent that hooks run twice, triggered by both 5 | # the Git commit-msg and the pre-commit hook. 6 | - commit 7 | 8 | repos: 9 | - repo: https://github.com/pre-commit/pre-commit-hooks 10 | rev: v4.4.0 11 | hooks: 12 | - id: check-case-conflict 13 | - id: check-json 14 | - id: check-merge-conflict 15 | - id: check-symlinks 16 | - id: check-toml 17 | - id: check-xml 18 | - id: check-yaml 19 | - id: destroyed-symlinks 20 | - id: detect-private-key 21 | - id: end-of-file-fixer 22 | - id: fix-byte-order-marker 23 | - id: forbid-new-submodules 24 | - id: mixed-line-ending 25 | - id: trailing-whitespace 26 | - repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook 27 | rev: v9.5.0 28 | hooks: 29 | - id: commitlint 30 | stages: 31 | - commit-msg 32 | - repo: https://github.com/DavidAnson/markdownlint-cli2 33 | rev: v0.9.2 34 | hooks: 35 | - id: markdownlint-cli2 36 | exclude: ^LICENSE\.md|doc/c4-plantuml/LayoutOptions\.md$ 37 | - repo: https://github.com/shellcheck-py/shellcheck-py 38 | rev: v0.9.0.5 39 | hooks: 40 | - id: shellcheck 41 | - repo: https://github.com/codespell-project/codespell 42 | rev: v2.2.5 43 | hooks: 44 | - id: codespell 45 | args: [--ignore-words=.codespellignore] 46 | exclude: "^LICENSE-.*$" 47 | - repo: https://github.com/sirosen/check-jsonschema 48 | rev: 0.26.3 49 | hooks: 50 | - id: check-github-actions 51 | - id: check-github-workflows 52 | - repo: https://github.com/pre-commit/mirrors-prettier 53 | rev: v3.0.3 54 | hooks: 55 | - id: prettier 56 | types_or: 57 | - markdown 58 | - yaml 59 | - repo: https://github.com/doublify/pre-commit-rust 60 | rev: v1.0 61 | hooks: 62 | - id: fmt 63 | args: [--all, --] 64 | - id: clippy 65 | args: 66 | [ 67 | --locked, 68 | --workspace, 69 | --all-features, 70 | --all-targets, 71 | --, 72 | -D, 73 | warnings, 74 | ] 75 | - repo: local 76 | hooks: 77 | - id: cargo-doc 78 | name: cargo-doc 79 | entry: env RUSTDOCFLAGS=-Dwarnings cargo 80 | language: system 81 | pass_filenames: false 82 | args: [doc, --locked, --workspace, --no-deps] 83 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | overrides: 2 | - files: 3 | - "**/*.yaml" 4 | - "**/*.yml" 5 | options: 6 | tabWidth: 2 7 | # in double quotes you can use escapes 8 | singleQuote: false 9 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | # https://doc.rust-lang.org/cargo/reference/resolver.html#resolver-versions 3 | # > If using a virtual workspace, the version should be specified in the [workspace] table 4 | resolver = "2" 5 | 6 | members = [ 7 | "crates/*", 8 | "plugins/*", 9 | ] 10 | 11 | [workspace.package] 12 | version = "0.3.7" 13 | homepage = "https://github.com/slowtec/msr" 14 | repository = "https://github.com/slowtec/msr" 15 | license = "MIT/Apache-2.0" 16 | edition = "2021" 17 | rust-version = "1.71" 18 | 19 | [patch.crates-io] 20 | msr = { path = "crates/msr" } 21 | msr-core = { path = "crates/msr-core" } 22 | msr-legacy = { path = "crates/msr-legacy" } 23 | msr-plugin = { path = "crates/msr-plugin" } 24 | msr-plugin-csv-event-journal = { path = "plugins/csv-event-journal" } 25 | msr-plugin-csv-register-recorder = { path = "plugins/csv-register-recorder" } 26 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 - 2023 slowtec GmbH 2 | 3 | Permission is hereby granted, free of charge, to any 4 | person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the 6 | Software without restriction, including without 7 | limitation the rights to use, copy, modify, merge, 8 | publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software 10 | is furnished to do so, subject to the following 11 | conditions: 12 | 13 | The above copyright notice and this permission notice 14 | shall be included in all copies or substantial portions 15 | of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 18 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 19 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 20 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 21 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 22 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 23 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 24 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 25 | DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # msr 2 | 3 | A [Rust](https://www.rust-lang.org) library for industrial automation. 4 | 5 | [![Crates.io version](https://img.shields.io/crates/v/msr.svg)](https://crates.io/crates/msr) 6 | [![Docs.rs](https://docs.rs/msr/badge.svg)](https://docs.rs/msr/) 7 | [![Security audit](https://github.com/slowtec/msr/actions/workflows/security-audit.yaml/badge.svg)](https://github.com/slowtec/msr/actions/workflows/security-audit.yaml) 8 | [![Continuous integration](https://github.com/slowtec/msr/actions/workflows/build-and-test.yaml/badge.svg)](https://github.com/slowtec/msr/actions/workflows/build-and-test.yaml) 9 | [![Apache 2.0 licensed](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](./LICENSE-APACHE) 10 | [![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE-MIT) 11 | 12 | ## DISCLAIMER 13 | 14 | **_Version 0.3.x is an experimental release for early prototyping. Breaking changes might occur even between minor releases._** 15 | 16 | ## Installation 17 | 18 | Add this to your `Cargo.toml`: 19 | 20 | ```toml 21 | [dependencies] 22 | msr = "0.3" 23 | ``` 24 | 25 | ## Development 26 | 27 | For executing common development tasks install [cargo just](https://github.com/casey/just) 28 | and run it without arguments to print the list of predefined _recipes_: 29 | 30 | ```shell 31 | cargo install just 32 | just 33 | 34 | ``` 35 | 36 | ## License 37 | 38 | Copyright (c) 2018 - 2023, [slowtec GmbH](https://www.slowtec.de) 39 | 40 | Licensed under either of 41 | 42 | - Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or 43 | ) 44 | - MIT license ([LICENSE-MIT](LICENSE-MIT) or 45 | ) 46 | 47 | at your option. 48 | -------------------------------------------------------------------------------- /crates/msr-core/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "msr-core" 3 | description = "Industrial Automation Toolbox - Common core components" 4 | homepage.workspace = true 5 | repository.workspace = true 6 | license.workspace = true 7 | edition.workspace = true 8 | version.workspace = true 9 | rust-version.workspace = true 10 | 11 | [dependencies] 12 | anyhow = "1.0.75" 13 | base64 = "0.21.3" 14 | log = "0.4.20" 15 | num-derive = "0.4.0" 16 | num-traits = "0.2.16" 17 | thiserror = "1.0.48" 18 | time = { version = "0.3.28", features = ["local-offset", "macros", "formatting", "parsing"] } 19 | 20 | csv = { version = "1.2.2", optional = true, default-features = false } 21 | serde = { version = "1.0.188", optional = true, default-features = false } 22 | serde_json = { version = "1.0.105", optional = true, default-features = false } 23 | thread-priority = { version = "0.13.1", optional = true, default-features = false } 24 | ulid = { version = "1.0.1", optional = true } 25 | 26 | [target.'cfg(loom)'.dependencies] 27 | loom = "0.6.1" 28 | 29 | [features] 30 | default = [] 31 | full = ["csv-event-journal", "csv-register-recorder", "realtime-worker-thread"] 32 | serde = ["dep:serde", "time/serde-human-readable"] 33 | event-journal = ["serde/derive", "ulid"] 34 | register-recorder = ["serde/derive"] 35 | csv-storage = ["serde", "csv"] 36 | csv-event-journal = ["event-journal", "csv-storage"] 37 | csv-register-recorder = ["register-recorder", "csv-storage"] 38 | realtime-worker-thread = ["thread-priority"] 39 | 40 | [dev-dependencies] 41 | serde_json = "1.0.105" 42 | tempfile = "3.8.0" 43 | msr-core = { path = ".", features = ["full"] } 44 | -------------------------------------------------------------------------------- /crates/msr-core/src/audit.rs: -------------------------------------------------------------------------------- 1 | //! Structs used for auditing 2 | use crate::time::Timestamp; 3 | 4 | #[derive(Debug, Clone, Eq, PartialEq)] 5 | pub struct Activity { 6 | pub when: Timestamp, 7 | pub who: T, 8 | } 9 | 10 | impl Activity { 11 | pub fn now(who: impl Into) -> Self { 12 | Self { 13 | when: Timestamp::now(), 14 | who: who.into(), 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /crates/msr-core/src/control/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::Measurement; 2 | 3 | pub mod cyclic; 4 | 5 | #[derive(Debug, Clone, PartialEq, Eq)] 6 | pub enum Value { 7 | Input(Input), 8 | Output(Output), 9 | } 10 | 11 | impl From> for Value { 12 | fn from(from: Output) -> Self { 13 | Self::Output(from) 14 | } 15 | } 16 | 17 | impl From> for Value { 18 | fn from(from: Input) -> Self { 19 | Self::Input(from) 20 | } 21 | } 22 | 23 | #[derive(Debug, Clone, PartialEq, Eq)] 24 | pub struct Input { 25 | pub observed: Option>, 26 | } 27 | 28 | impl Input { 29 | #[must_use] 30 | pub const fn new() -> Self { 31 | Self { observed: None } 32 | } 33 | } 34 | 35 | impl Default for Input { 36 | fn default() -> Self { 37 | Self::new() 38 | } 39 | } 40 | 41 | #[derive(Debug, Clone, PartialEq, Eq)] 42 | pub struct Output { 43 | pub observed: Option>, 44 | pub desired: Option>, 45 | } 46 | 47 | impl Output { 48 | #[must_use] 49 | pub const fn new() -> Self { 50 | Self { 51 | observed: None, 52 | desired: None, 53 | } 54 | } 55 | } 56 | 57 | impl Default for Output { 58 | fn default() -> Self { 59 | Self::new() 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /crates/msr-core/src/event_journal/csv.rs: -------------------------------------------------------------------------------- 1 | use std::{num::NonZeroUsize, path::PathBuf, time::SystemTime}; 2 | 3 | use ::csv::Reader as CsvReader; 4 | 5 | use crate::{ 6 | fs::{ 7 | policy::{RollingFileInfo, RollingFileNameTemplate}, 8 | WriteResult, 9 | }, 10 | storage::{ 11 | self, csv, BinaryDataFormat, CreatedAtOffset, RecordStorageBase, RecordStorageRead, 12 | RecordStorageWrite, StorageConfig, StorageDescriptor, StorageStatistics, 13 | MAX_PREALLOCATED_CAPACITY_LIMIT, 14 | }, 15 | time::SystemInstant, 16 | }; 17 | 18 | use super::{Error, Record, RecordFilter, RecordStorage, Result, StorageRecord, StoredRecord}; 19 | 20 | #[allow(missing_debug_implementations)] 21 | pub struct FileRecordStorage { 22 | inner: csv::FileRecordStorage, 23 | } 24 | 25 | impl FileRecordStorage { 26 | pub fn try_new( 27 | base_path: PathBuf, 28 | file_name_prefix: String, 29 | binary_data_format: BinaryDataFormat, 30 | initial_config: StorageConfig, 31 | ) -> Result { 32 | let file_name_template = RollingFileNameTemplate { 33 | prefix: file_name_prefix, 34 | suffix: ".csv".to_string(), 35 | }; 36 | let inner = csv::FileRecordStorage::try_new( 37 | binary_data_format, 38 | initial_config, 39 | base_path, 40 | file_name_template, 41 | None, 42 | )?; 43 | Ok(Self { inner }) 44 | } 45 | } 46 | 47 | fn filter_map_storage_record( 48 | created_at_origin: SystemTime, 49 | record: StorageRecord, 50 | binary_data_format: BinaryDataFormat, 51 | ) -> Option { 52 | match StoredRecord::try_restore(created_at_origin, record, binary_data_format) { 53 | Ok(record) => Some(record), 54 | Err(err) => { 55 | // This should never happen 56 | log::error!("Failed to convert record: {}", err); 57 | // Skip and continue 58 | None 59 | } 60 | } 61 | } 62 | 63 | impl RecordStorageBase for FileRecordStorage { 64 | fn descriptor(&self) -> &StorageDescriptor { 65 | self.inner.descriptor() 66 | } 67 | 68 | fn config(&self) -> &StorageConfig { 69 | self.inner.config() 70 | } 71 | 72 | fn replace_config(&mut self, new_config: StorageConfig) -> StorageConfig { 73 | self.inner.replace_config(new_config) 74 | } 75 | 76 | fn perform_housekeeping(&mut self) -> storage::Result<()> { 77 | self.inner.perform_housekeeping() 78 | } 79 | 80 | fn retain_all_records_created_since( 81 | &mut self, 82 | created_since: SystemTime, 83 | ) -> storage::Result<()> { 84 | self.inner.retain_all_records_created_since(created_since) 85 | } 86 | 87 | fn report_statistics(&mut self) -> storage::Result { 88 | self.inner.report_statistics() 89 | } 90 | } 91 | 92 | impl RecordStorageWrite for FileRecordStorage { 93 | fn append_record( 94 | &mut self, 95 | created_at: &SystemInstant, 96 | record: Record, 97 | ) -> storage::Result<(WriteResult, CreatedAtOffset)> { 98 | let storage_record = StorageRecord::try_new(record, self.descriptor().binary_data_format)?; 99 | self.inner.append_record(created_at, storage_record) 100 | } 101 | } 102 | 103 | impl RecordStorage for FileRecordStorage { 104 | fn recent_records(&mut self, limit: NonZeroUsize) -> Result> { 105 | // TODO: How to avoid conversion of (intermediate) vectors? 106 | self.inner 107 | .recent_records(limit) 108 | .map(|v| { 109 | v.into_iter() 110 | // TODO: filter_map() may drop some inconvertible records that have 111 | // not been accounted for when prematurely limiting the results 112 | // requested from self.inner (see above)! This should not happen 113 | // and an error is logged. 114 | .filter_map(|(create_at_origin, record)| { 115 | filter_map_storage_record( 116 | create_at_origin, 117 | record, 118 | self.descriptor().binary_data_format, 119 | ) 120 | }) 121 | .collect() 122 | }) 123 | .map_err(Error::Storage) 124 | } 125 | 126 | fn filter_records( 127 | &mut self, 128 | limit: NonZeroUsize, 129 | filter: RecordFilter, 130 | ) -> Result> { 131 | self.inner.flush_before_reading()?; 132 | let limit = limit.get().min(MAX_PREALLOCATED_CAPACITY_LIMIT); 133 | let mut records = Vec::with_capacity(limit); 134 | for file_info in self 135 | .inner 136 | .read_all_dir_entries_filtered_chronologically( 137 | &csv::file_info_filter_from_record_prelude_filter(&filter.prelude), 138 | )? 139 | .into_iter() 140 | .map(RollingFileInfo::from) 141 | { 142 | if limit <= records.len() { 143 | break; 144 | } 145 | let remaining_limit = limit - records.len(); 146 | let reader = csv::create_file_reader(&file_info.path)?; 147 | records.extend( 148 | reader_into_filtered_record_iter( 149 | reader, 150 | file_info.created_at.into(), 151 | filter.clone(), 152 | self.descriptor().binary_data_format, 153 | ) 154 | .take(remaining_limit), 155 | ); 156 | } 157 | Ok(records) 158 | } 159 | } 160 | 161 | fn reader_into_filtered_record_iter( 162 | reader: CsvReader, 163 | created_at_origin: SystemTime, 164 | filter: RecordFilter, 165 | binary_data_format: BinaryDataFormat, 166 | ) -> impl Iterator 167 | where 168 | R: std::io::Read, 169 | { 170 | let RecordFilter { 171 | prelude: prelude_filter, 172 | any_codes, 173 | any_scopes, 174 | min_severity, 175 | } = filter; 176 | csv::reader_into_filtered_record_iter(reader, created_at_origin, prelude_filter) 177 | .filter_map(move |record| { 178 | filter_map_storage_record(created_at_origin, record, binary_data_format) 179 | }) 180 | .filter(move |StoredRecord { prelude: _, entry }| { 181 | if let Some(min_severity) = min_severity { 182 | if entry.severity < min_severity { 183 | return false; 184 | } 185 | } 186 | if let Some(any_codes) = &any_codes { 187 | if any_codes.iter().all(|code| *code != entry.code) { 188 | return false; 189 | } 190 | } 191 | if let Some(any_scopes) = &any_scopes { 192 | if any_scopes.iter().all(|scope| scope != &entry.scope) { 193 | return false; 194 | } 195 | } 196 | true 197 | }) 198 | } 199 | -------------------------------------------------------------------------------- /crates/msr-core/src/fs/csv/tests.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use tempfile::TempDir; 4 | 5 | use crate::fs::policy::{RollingFileNameTemplate, RollingFileSystem}; 6 | 7 | use super::*; 8 | 9 | #[test] 10 | fn write_records_with_max_bytes_written_limit() { 11 | let temp_dir = TempDir::new().unwrap(); 12 | let config = RollingFileConfig { 13 | system: RollingFileSystem { 14 | base_path: temp_dir.path().to_path_buf(), 15 | file_name_template: RollingFileNameTemplate { 16 | prefix: "prefix_".into(), 17 | suffix: "_suffix.csv".into(), 18 | }, 19 | }, 20 | limits: RollingFileLimits { 21 | max_bytes_written: Some(5), 22 | max_records_written: None, 23 | max_nanoseconds_offset: None, 24 | interval: None, 25 | }, 26 | }; 27 | let mut writer = RollingFileWriter::new(config, None); 28 | assert!(writer.current_file_info().is_none()); 29 | assert_eq!( 30 | (Ok(()), None), 31 | writer 32 | .write_record(&SystemInstant::now(), 0, ["hello", "1.0"]) 33 | .unwrap() 34 | ); 35 | // Flushing is required to clear the internal buffers and 36 | // increment the bytes_written counter! 37 | assert!(writer.flush().is_ok()); 38 | assert!(writer.current_file_info().is_some()); 39 | let initial_file_info = writer.current_file_info().cloned(); 40 | assert!(initial_file_info.is_some()); 41 | let delta_t = Duration::from_secs(1); 42 | let (record_written, closed_file_info) = writer 43 | .write_record( 44 | &(SystemInstant::now() + delta_t), 45 | delta_t.as_nanos() as u64, 46 | ["world", "-1.0"], 47 | ) 48 | .unwrap(); 49 | assert!(record_written.is_ok()); 50 | assert_eq!(initial_file_info.map(ClosedFileInfo), closed_file_info); 51 | assert!(writer.current_file_info().is_some()); 52 | assert_ne!( 53 | writer.current_file_info(), 54 | closed_file_info.map(ClosedFileInfo::into_inner).as_ref() 55 | ); 56 | } 57 | 58 | #[test] 59 | fn write_records_with_max_records_written_limits() { 60 | let temp_dir = TempDir::new().unwrap(); 61 | let config = RollingFileConfig { 62 | system: RollingFileSystem { 63 | base_path: temp_dir.path().to_path_buf(), 64 | file_name_template: RollingFileNameTemplate { 65 | prefix: "prefix_".into(), 66 | suffix: "_suffix.csv".into(), 67 | }, 68 | }, 69 | limits: RollingFileLimits { 70 | max_bytes_written: None, 71 | max_records_written: Some(1), 72 | max_nanoseconds_offset: None, 73 | interval: None, 74 | }, 75 | }; 76 | let mut writer = RollingFileWriter::new(config, None); 77 | assert!(writer.current_file_info().is_none()); 78 | assert_eq!( 79 | (Ok(()), None), 80 | writer 81 | .write_record(&SystemInstant::now(), 0, ["hello", "1.0"]) 82 | .unwrap() 83 | ); 84 | // Flushing is required to clear the internal buffers and 85 | // increment the bytes_written counter! 86 | assert!(writer.current_file_info().is_some()); 87 | let initial_file_info = writer.current_file_info().cloned(); 88 | assert!(initial_file_info.is_some()); 89 | let delta_t = Duration::from_secs(1); 90 | let (record_written, closed_file_info) = writer 91 | .write_record( 92 | &(SystemInstant::now() + delta_t), 93 | delta_t.as_nanos() as u64, 94 | ["world", "-1.0"], 95 | ) 96 | .unwrap(); 97 | assert!(record_written.is_ok()); 98 | assert_eq!(initial_file_info.map(ClosedFileInfo), closed_file_info); 99 | assert!(writer.current_file_info().is_some()); 100 | assert_ne!( 101 | writer.current_file_info(), 102 | closed_file_info.map(ClosedFileInfo::into_inner).as_ref() 103 | ); 104 | } 105 | -------------------------------------------------------------------------------- /crates/msr-core/src/fs/mod.rs: -------------------------------------------------------------------------------- 1 | use std::result::Result as StdResult; 2 | 3 | use thiserror::Error; 4 | 5 | pub mod policy; 6 | 7 | #[cfg(feature = "csv-storage")] 8 | pub mod csv; 9 | 10 | #[derive(Error, Debug)] 11 | pub enum Error { 12 | #[error("timing error")] 13 | Timing, 14 | 15 | #[cfg(feature = "csv-storage")] 16 | #[error("CSV format error")] 17 | Csv(::csv::Error), 18 | } 19 | 20 | #[cfg(feature = "csv-storage")] 21 | impl From<::csv::Error> for Error { 22 | fn from(from: ::csv::Error) -> Self { 23 | Self::Csv(from) 24 | } 25 | } 26 | 27 | pub type Result = StdResult; 28 | 29 | #[derive(Error, Debug, Eq, PartialEq)] 30 | pub enum WriteError { 31 | #[error("no file available for writing")] 32 | NoFile, 33 | 34 | #[error("writing repeatedly failed with OS error code {code:}")] 35 | RepeatedOsError { code: i32 }, 36 | } 37 | 38 | pub type WriteResult = StdResult<(), WriteError>; 39 | -------------------------------------------------------------------------------- /crates/msr-core/src/fs/policy/tests.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | path::{Path, MAIN_SEPARATOR}, 3 | time::Duration, 4 | }; 5 | 6 | use super::*; 7 | 8 | fn verify_file_path( 9 | actual_file_path: &Path, 10 | rolling_fs: &RollingFileSystem, 11 | created_at: FileNameTimeStamp, 12 | ) { 13 | let RollingFileSystem { 14 | base_path, 15 | file_name_template, 16 | } = rolling_fs; 17 | let actual_file_name = actual_file_path.file_name().unwrap(); 18 | assert_eq!( 19 | created_at, 20 | file_name_template 21 | .parse_time_stamp_from_file_name(actual_file_name) 22 | .unwrap() 23 | ); 24 | let RollingFileNameTemplate { 25 | prefix: file_name_prefix, 26 | suffix: file_name_suffix, 27 | } = file_name_template; 28 | let actual_file_path_str = actual_file_path.to_str().unwrap(); 29 | let base_path_str = base_path.to_str().unwrap(); 30 | assert!(actual_file_path_str.starts_with(base_path_str)); 31 | assert!(actual_file_path_str.contains(MAIN_SEPARATOR)); 32 | assert!(actual_file_path_str.contains(file_name_prefix)); 33 | let created_at = Timestamp::from(SystemTime::from(created_at)); 34 | let expected_file_name =format!( 35 | "{prefix}{year:04}{month:02}{day:02}T{hour:02}{minute:02}{second:02}.{nanosecond:09}Z{suffix}", 36 | prefix = file_name_prefix, 37 | year = created_at.year(), 38 | month = created_at.month() as u8, 39 | day = created_at.day(), 40 | hour = created_at.hour(), 41 | minute = created_at.minute(), 42 | second = created_at.second(), 43 | nanosecond = created_at.nanosecond(), 44 | suffix = file_name_suffix, 45 | ); 46 | assert!(actual_file_path_str.ends_with(&expected_file_name)); 47 | assert_eq!( 48 | actual_file_path_str.find(MAIN_SEPARATOR).unwrap(), 49 | actual_file_path_str.find(base_path_str).unwrap() + base_path_str.len() 50 | ); 51 | assert_eq!( 52 | actual_file_path_str.find(file_name_prefix).unwrap(), 53 | actual_file_path_str.find(MAIN_SEPARATOR).unwrap() + MAIN_SEPARATOR.to_string().len() 54 | ); 55 | assert_eq!( 56 | actual_file_path_str.find(file_name_suffix).unwrap(), 57 | actual_file_path_str.find(file_name_prefix).unwrap() 58 | + file_name_prefix.len() 59 | + TIME_STAMP_STRING_LEN 60 | ); 61 | } 62 | 63 | #[test] 64 | fn format_file_name_from_config() { 65 | let cfg = RollingFileSystem { 66 | base_path: ".".into(), 67 | file_name_template: RollingFileNameTemplate { 68 | prefix: "prefix_".into(), 69 | suffix: "_suffix.ext".into(), 70 | }, 71 | }; 72 | 73 | let created_at = 74 | SystemTime::from(Timestamp::parse_rfc3339("1978-01-02T23:04:05.12345678Z").unwrap()).into(); 75 | let file_path = cfg.new_file_path(created_at); 76 | verify_file_path(&file_path, &cfg, created_at); 77 | 78 | let created_at = SystemTime::now().into(); 79 | let file_path = cfg.new_file_path(created_at); 80 | verify_file_path(&file_path, &cfg, created_at); 81 | } 82 | 83 | #[test] 84 | fn file_info_cmp_created_at_later() { 85 | let now = SystemTime::now(); 86 | let earlier = RollingFileInfoWithSize { 87 | path: Default::default(), 88 | created_at: now.into(), 89 | size_in_bytes: 0, 90 | }; 91 | let later = RollingFileInfoWithSize { 92 | path: Default::default(), 93 | created_at: (now + Duration::from_secs(1)).into(), 94 | size_in_bytes: 0, 95 | }; 96 | assert_eq!(Ordering::Less, earlier.cmp_created_at(&later)); 97 | assert_eq!(Ordering::Equal, earlier.cmp_created_at(&earlier)); 98 | assert_eq!(Ordering::Equal, later.cmp_created_at(&later)); 99 | assert_eq!(Ordering::Greater, later.cmp_created_at(&earlier)); 100 | } 101 | -------------------------------------------------------------------------------- /crates/msr-core/src/io/mod.rs: -------------------------------------------------------------------------------- 1 | //! I/O related utilities 2 | 3 | use std::io::{Result, Write}; 4 | 5 | use crate::sync::{ 6 | atomic::{AtomicU64, Ordering}, 7 | Arc, 8 | }; 9 | 10 | #[derive(Clone, Debug)] 11 | pub struct BytesWritten(Arc); 12 | 13 | impl BytesWritten { 14 | #[must_use] 15 | pub fn value(&self) -> u64 { 16 | self.0.load(Ordering::Relaxed) 17 | } 18 | } 19 | 20 | #[derive(Debug)] 21 | pub struct CountingWrite { 22 | writer: W, 23 | bytes_written: Arc, 24 | } 25 | 26 | impl CountingWrite { 27 | /// Wrap a writer and start counting 28 | pub fn from_writer(writer: W) -> (Self, BytesWritten) { 29 | let bytes_written = Arc::new(AtomicU64::new(0)); 30 | ( 31 | Self { 32 | writer, 33 | bytes_written: Arc::clone(&bytes_written), 34 | }, 35 | BytesWritten(bytes_written), 36 | ) 37 | } 38 | 39 | /// Dismantle the wrapped writer and stop counting 40 | pub fn into_value(self) -> W { 41 | self.writer 42 | } 43 | } 44 | 45 | impl Write for CountingWrite { 46 | fn write(&mut self, buf: &[u8]) -> Result { 47 | let bytes_written = self.writer.write(buf)?; 48 | // self has exclusive mutable access on the number of octets written, i.e. 49 | // we can safely get-modify-set this value without race conditions here! 50 | let mut sum_bytes_written = self.bytes_written.load(Ordering::Relaxed); 51 | sum_bytes_written = sum_bytes_written.saturating_add(bytes_written as u64); 52 | self.bytes_written 53 | .store(sum_bytes_written, Ordering::Relaxed); 54 | Ok(bytes_written) 55 | } 56 | 57 | fn flush(&mut self) -> Result<()> { 58 | self.writer.flush() 59 | } 60 | } 61 | 62 | #[cfg(test)] 63 | mod tests; 64 | -------------------------------------------------------------------------------- /crates/msr-core/src/io/tests.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn counting_write() { 5 | let (mut writer, bytes_written) = CountingWrite::from_writer(Vec::with_capacity(100)); 6 | assert_eq!(0, bytes_written.value()); 7 | assert!(writer.write(&[1]).is_ok()); 8 | assert_eq!(1, bytes_written.value()); 9 | assert!(writer.write(&[2, 3]).is_ok()); 10 | assert_eq!(3, bytes_written.value()); 11 | assert!(writer.write(&[]).is_ok()); 12 | assert_eq!(3, bytes_written.value()); 13 | assert!(writer.write(&[4, 5, 6, 7]).is_ok()); 14 | assert_eq!(7, bytes_written.value()); 15 | } 16 | -------------------------------------------------------------------------------- /crates/msr-core/src/lib.rs: -------------------------------------------------------------------------------- 1 | // FIXME: Enable `deny(missing_docs)` before release 2 | //#![deny(missing_docs)] 3 | 4 | #![warn(rust_2018_idioms)] 5 | #![warn(rust_2021_compatibility)] 6 | #![warn(missing_debug_implementations)] 7 | #![warn(unreachable_pub)] 8 | #![warn(unsafe_code)] 9 | #![warn(rustdoc::broken_intra_doc_links)] 10 | #![warn(clippy::pedantic)] 11 | // Additional restrictions 12 | #![warn(clippy::clone_on_ref_ptr)] 13 | #![warn(clippy::self_named_module_files)] 14 | // Exceptions 15 | #![allow(clippy::default_trait_access)] 16 | #![allow(clippy::enum_glob_use)] 17 | #![allow(clippy::module_name_repetitions)] 18 | #![allow(clippy::missing_errors_doc)] // TODO 19 | #![allow(clippy::cast_possible_truncation)] // TODO 20 | #![allow(clippy::cast_possible_wrap)] // TODO 21 | 22 | //! Industrial Automation Toolbox - Common core components 23 | 24 | mod measure; 25 | mod value; 26 | 27 | pub use self::{measure::*, value::*}; 28 | 29 | pub mod audit; 30 | pub mod control; 31 | pub mod fs; 32 | pub mod io; 33 | pub mod register; 34 | pub mod storage; 35 | pub mod sync; 36 | pub mod thread; 37 | pub mod time; 38 | 39 | #[cfg(feature = "realtime-worker-thread")] 40 | pub mod realtime; 41 | 42 | #[cfg(feature = "event-journal")] 43 | pub mod event_journal; 44 | -------------------------------------------------------------------------------- /crates/msr-core/src/measure.rs: -------------------------------------------------------------------------------- 1 | use std::time::Instant; 2 | 3 | #[derive(Debug, Clone, PartialEq, Eq)] 4 | pub struct Measurement { 5 | /// A time stamp 6 | pub ts: Instant, 7 | 8 | /// The measured value 9 | pub val: Option, 10 | } 11 | -------------------------------------------------------------------------------- /crates/msr-core/src/realtime/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod worker; 2 | -------------------------------------------------------------------------------- /crates/msr-core/src/realtime/worker/mod.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | 3 | pub mod progress; 4 | use self::progress::ProgressHintReceiver; 5 | 6 | pub mod thread; 7 | 8 | /// Completion status 9 | /// 10 | /// Reflects the intention on how to proceed after performing some 11 | /// work. 12 | /// 13 | /// Supposed to affect the subsequent control flow outside of the 14 | /// worker's context. 15 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 16 | pub enum CompletionStatus { 17 | /// Working should be suspended 18 | /// 19 | /// The worker currently has no more pending work to do. 20 | Suspending, 21 | 22 | /// Working should be finished 23 | /// 24 | /// The worker has accomplished its task and expects to be 25 | /// finished. 26 | Finishing, 27 | } 28 | 29 | /// Callback interface for performing work under real-time constraints 30 | /// 31 | /// All invocations between `start_working`() and `finish_working`() 32 | /// will happen on the same thread, including those two clamping functions. 33 | /// 34 | /// ```puml 35 | /// @startuml 36 | /// participant Worker 37 | /// 38 | /// -> Worker: start_working() 39 | /// activate Worker 40 | /// <- Worker: started 41 | /// deactivate Worker 42 | /// 43 | /// -> Worker: perform_work() 44 | /// activate Worker 45 | /// <- Worker: CompletionStatus::Suspending 46 | /// deactivate Worker 47 | /// 48 | /// ... 49 | /// 50 | /// -> Worker: perform_work() 51 | /// activate Worker 52 | /// <- Worker: CompletionStatus::Finishing 53 | /// deactivate Worker 54 | /// 55 | /// -> Worker: finish_working() 56 | /// activate Worker 57 | /// <- Worker: finished 58 | /// deactivate Worker 59 | /// @enduml 60 | /// ``` 61 | pub trait Worker { 62 | /// The environment is provided as an external invocation context 63 | /// to every function of the worker. 64 | type Environment; 65 | 66 | /// Start working 67 | /// 68 | /// Invoked once before the first call to [`Worker::perform_work()`] for 69 | /// acquiring resources and initializing the internal state. 70 | fn start_working(&mut self, env: &mut Self::Environment) -> Result<()>; 71 | 72 | /// Perform work 73 | /// 74 | /// Make progress until work is either interrupted by a progress hint 75 | /// or done. 76 | /// 77 | /// This function is invoked at least once after [`Worker::start_working()`] 78 | /// has returned successfully. It will be invoked repeatedly until finally 79 | /// [`Worker::finish_working()`] is invoked. 80 | /// 81 | /// This function is not supposed to mutate the environment. 82 | /// 83 | /// Returns a completion status that indicates how to proceed. 84 | fn perform_work( 85 | &mut self, 86 | env: &Self::Environment, 87 | progress_hint_rx: &ProgressHintReceiver, 88 | ) -> Result; 89 | 90 | /// Finish working 91 | /// 92 | /// Invoked once after the last call to [`Worker::perform_work()`] for 93 | /// finalizing results, releasing resources, and performing cleanup. 94 | fn finish_working(&mut self, env: &mut Self::Environment) -> Result<()>; 95 | } 96 | -------------------------------------------------------------------------------- /crates/msr-core/src/realtime/worker/progress/tests.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn atomic_progress_hint_default() { 5 | assert_eq!( 6 | ProgressHint::default(), 7 | AtomicProgressHint::default().peek() 8 | ); 9 | } 10 | 11 | #[test] 12 | /// Test the behavior of the underlying state machine in isolation (single-threaded) 13 | fn atomic_progress_hint_sequence() { 14 | let progress_hint = AtomicProgressHint::default(); 15 | assert_eq!(ProgressHint::Continue, progress_hint.load()); 16 | 17 | // Suspend 18 | assert_eq!( 19 | SwitchAtomicStateOk::Accepted { 20 | previous_state: ProgressHint::Continue, 21 | }, 22 | progress_hint.suspend().unwrap() 23 | ); 24 | assert_eq!(ProgressHint::Suspend, progress_hint.load()); 25 | 26 | // Suspend again 27 | assert_eq!( 28 | SwitchAtomicStateOk::Ignored, 29 | progress_hint.suspend().unwrap() 30 | ); 31 | assert_eq!(ProgressHint::Suspend, progress_hint.load()); 32 | 33 | // Resume 34 | assert_eq!( 35 | SwitchAtomicStateOk::Accepted { 36 | previous_state: ProgressHint::Suspend, 37 | }, 38 | progress_hint.resume().unwrap() 39 | ); 40 | assert_eq!(ProgressHint::Continue, progress_hint.load()); 41 | 42 | // Resume again 43 | assert_eq!( 44 | SwitchAtomicStateOk::Ignored, 45 | progress_hint.resume().unwrap() 46 | ); 47 | assert_eq!(ProgressHint::Continue, progress_hint.load()); 48 | 49 | // Finish while running 50 | assert_eq!( 51 | SwitchAtomicStateOk::Accepted { 52 | previous_state: ProgressHint::Continue, 53 | }, 54 | progress_hint.finish().unwrap() 55 | ); 56 | assert_eq!(ProgressHint::Finish, progress_hint.load()); 57 | 58 | // Finish again 59 | assert_eq!( 60 | SwitchAtomicStateOk::Ignored, 61 | progress_hint.finish().unwrap() 62 | ); 63 | assert_eq!(ProgressHint::Finish, progress_hint.load()); 64 | 65 | // Reset after finished 66 | assert_eq!( 67 | SwitchAtomicStateOk::Accepted { 68 | previous_state: ProgressHint::Finish, 69 | }, 70 | progress_hint.reset() 71 | ); 72 | assert_eq!(ProgressHint::Continue, progress_hint.load()); 73 | 74 | // Reset again 75 | assert_eq!(SwitchAtomicStateOk::Ignored, progress_hint.reset()); 76 | assert_eq!(ProgressHint::Continue, progress_hint.load()); 77 | 78 | // Finish while suspended 79 | assert_eq!( 80 | SwitchAtomicStateOk::Accepted { 81 | previous_state: ProgressHint::Continue, 82 | }, 83 | progress_hint.suspend().unwrap() 84 | ); 85 | assert_eq!(ProgressHint::Suspend, progress_hint.load()); 86 | assert_eq!( 87 | SwitchAtomicStateOk::Accepted { 88 | previous_state: ProgressHint::Suspend, 89 | }, 90 | progress_hint.finish().unwrap() 91 | ); 92 | assert_eq!(ProgressHint::Finish, progress_hint.load()); 93 | 94 | // Reject suspend after finished 95 | assert_eq!( 96 | Err(SwitchAtomicStateErr::Rejected { 97 | current_state: ProgressHint::Finish, 98 | }), 99 | progress_hint.suspend() 100 | ); 101 | assert_eq!(ProgressHint::Finish, progress_hint.load()); 102 | 103 | // Reject resume after finished 104 | assert_eq!( 105 | Err(SwitchAtomicStateErr::Rejected { 106 | current_state: ProgressHint::Finish, 107 | }), 108 | progress_hint.resume() 109 | ); 110 | assert_eq!(ProgressHint::Finish, progress_hint.load()); 111 | } 112 | 113 | #[test] 114 | fn progress_hint_sender_receiver_attach_detach() -> anyhow::Result<()> { 115 | let mut rx = ProgressHintReceiver::default(); 116 | 117 | // Attach and test 1st sender 118 | let tx1 = ProgressHintSender::attach(&rx); 119 | assert!(tx1.is_attached()); 120 | assert_eq!( 121 | SwitchProgressHintOk::Accepted { 122 | previous_state: ProgressHint::Continue, 123 | }, 124 | tx1.suspend()? 125 | ); 126 | assert_eq!(ProgressHint::Suspend, rx.load()); 127 | 128 | // Attach and test 2nd sender by cloning the 1st sender 129 | #[allow(clippy::redundant_clone)] 130 | let tx2 = tx1.clone(); 131 | assert!(tx2.is_attached()); 132 | assert_eq!( 133 | SwitchProgressHintOk::Accepted { 134 | previous_state: ProgressHint::Suspend, 135 | }, 136 | tx2.resume()? 137 | ); 138 | assert_eq!(ProgressHint::Continue, rx.load()); 139 | 140 | // Detach the receiver 141 | rx.detach(); 142 | 143 | // Both senders are detached now 144 | assert!(!tx1.is_attached()); 145 | assert!(!tx2.is_attached()); 146 | 147 | // All subsequent attempts to switch the progress hint fail 148 | assert!(matches!( 149 | tx1.finish(), 150 | Err(SwitchProgressHintError::Detached) 151 | )); 152 | assert!(matches!( 153 | tx2.finish(), 154 | Err(SwitchProgressHintError::Detached) 155 | )); 156 | 157 | Ok(()) 158 | } 159 | 160 | #[test] 161 | fn progress_hint_handover_temporal_decoupling_of_sender_receiver() -> anyhow::Result<()> { 162 | let rx = ProgressHintReceiver::default(); 163 | 164 | // No update has been sent yet 165 | assert!(!rx.wait_until(Instant::now())); 166 | 167 | let tx = ProgressHintSender::attach(&rx); 168 | assert!(tx.is_attached()); 169 | assert_eq!( 170 | SwitchProgressHintOk::Accepted { 171 | previous_state: ProgressHint::Continue, 172 | }, 173 | tx.finish()? 174 | ); 175 | assert_eq!(ProgressHint::Finish, rx.load()); 176 | 177 | // Drop the sender before the receiver notices the update 178 | drop(tx); 179 | 180 | // The receiver should notice the pending update by now 181 | assert!(rx.wait_until(Instant::now())); 182 | 183 | Ok(()) 184 | } 185 | 186 | #[test] 187 | fn progress_hint_handover_consume_single_update_notification_once() -> anyhow::Result<()> { 188 | let rx = ProgressHintReceiver::default(); 189 | let tx = ProgressHintSender::attach(&rx); 190 | assert!(tx.is_attached()); 191 | 192 | // No update has been sent yet 193 | assert!(!rx.wait_until(Instant::now())); 194 | 195 | // Continue -> Suspend 196 | assert_eq!( 197 | SwitchProgressHintOk::Accepted { 198 | previous_state: ProgressHint::Continue, 199 | }, 200 | tx.suspend()? 201 | ); 202 | assert_eq!(ProgressHint::Suspend, rx.load()); 203 | 204 | // Consume update notification once 205 | assert!(rx.wait_until(Instant::now())); 206 | assert!(!rx.wait_until(Instant::now())); 207 | 208 | // Suspend -> Continue 209 | assert_eq!( 210 | SwitchProgressHintOk::Accepted { 211 | previous_state: ProgressHint::Suspend, 212 | }, 213 | tx.resume()? 214 | ); 215 | assert_eq!(ProgressHint::Continue, rx.load()); 216 | // Continue -> Finish 217 | assert_eq!( 218 | SwitchProgressHintOk::Accepted { 219 | previous_state: ProgressHint::Continue, 220 | }, 221 | tx.finish()? 222 | ); 223 | assert_eq!(ProgressHint::Finish, rx.load()); 224 | 225 | // Consume single notification once after 2 updates 226 | assert!(rx.wait_until(Instant::now())); 227 | assert!(!rx.wait_until(Instant::now())); 228 | 229 | Ok(()) 230 | } 231 | 232 | #[test] 233 | fn progress_hint_handover_try_switch_without_update_notification() { 234 | let mut rx = ProgressHintReceiver::default(); 235 | 236 | // No update has been sent yet 237 | assert!(!rx.wait_until(Instant::now())); 238 | 239 | // Continue -> Suspend 240 | assert!(rx.try_suspending()); 241 | assert_eq!(ProgressHint::Suspend, rx.load()); 242 | 243 | // No update notification after try_suspending() 244 | assert!(!rx.wait_until(Instant::now())); 245 | 246 | // Suspend -> Finish 247 | assert!(rx.try_finishing()); 248 | assert_eq!(ProgressHint::Finish, rx.load()); 249 | 250 | // No update notification after try_finishing() 251 | assert!(!rx.wait_until(Instant::now())); 252 | } 253 | -------------------------------------------------------------------------------- /crates/msr-core/src/realtime/worker/thread/tests.rs: -------------------------------------------------------------------------------- 1 | use crate::realtime::worker::progress::{ProgressHint, ProgressHintSender, SwitchProgressHintOk}; 2 | 3 | use super::*; 4 | 5 | struct SmokeTestEnvironment; 6 | 7 | #[derive(Default)] 8 | struct SmokeTestWorker { 9 | start_working_invocations: usize, 10 | finish_working_invocations: usize, 11 | actual_perform_work_invocations: usize, 12 | expected_perform_work_invocations: usize, 13 | } 14 | 15 | impl SmokeTestWorker { 16 | fn new(expected_perform_work_invocations: usize) -> Self { 17 | Self { 18 | expected_perform_work_invocations, 19 | ..Default::default() 20 | } 21 | } 22 | } 23 | 24 | impl Worker for SmokeTestWorker { 25 | type Environment = SmokeTestEnvironment; 26 | 27 | fn start_working(&mut self, _env: &mut Self::Environment) -> Result<()> { 28 | self.start_working_invocations += 1; 29 | Ok(()) 30 | } 31 | 32 | fn finish_working(&mut self, _env: &mut Self::Environment) -> Result<()> { 33 | self.finish_working_invocations += 1; 34 | Ok(()) 35 | } 36 | 37 | fn perform_work( 38 | &mut self, 39 | _env: &Self::Environment, 40 | progress_hint_rx: &ProgressHintReceiver, 41 | ) -> Result { 42 | self.actual_perform_work_invocations += 1; 43 | let progress = match progress_hint_rx.peek() { 44 | ProgressHint::Continue => { 45 | assert!( 46 | self.actual_perform_work_invocations <= self.expected_perform_work_invocations 47 | ); 48 | if self.actual_perform_work_invocations < self.expected_perform_work_invocations { 49 | CompletionStatus::Suspending 50 | } else { 51 | CompletionStatus::Finishing 52 | } 53 | } 54 | ProgressHint::Suspend => CompletionStatus::Suspending, 55 | ProgressHint::Finish => CompletionStatus::Finishing, 56 | }; 57 | Ok(progress) 58 | } 59 | } 60 | 61 | #[test] 62 | fn smoke_test() -> anyhow::Result<()> { 63 | for expected_perform_work_invocations in 1..10 { 64 | let worker = SmokeTestWorker::new(expected_perform_work_invocations); 65 | let progress_hint_rx = ProgressHintReceiver::default(); 66 | let progress_hint_tx = ProgressHintSender::attach(&progress_hint_rx); 67 | let context = Context { 68 | progress_hint_rx, 69 | worker, 70 | environment: SmokeTestEnvironment, 71 | }; 72 | // Real-time thread scheduling might not be supported when running the tests 73 | // in containers on CI platforms. 74 | let worker_thread = WorkerThread::spawn(context, ThreadScheduling::Default); 75 | let mut resume_accepted = 0; 76 | loop { 77 | match worker_thread.load_state() { 78 | State::Initial | State::Starting | State::Finishing | State::Running => (), 79 | State::Suspending => match progress_hint_tx.resume() { 80 | Ok(SwitchProgressHintOk::Accepted { .. }) => { 81 | resume_accepted += 1; 82 | } 83 | // The worker thread might already have terminated itself, which in turn 84 | // detaches our `ProgressHintSender`. 85 | Ok(SwitchProgressHintOk::Ignored) | Err(_) => (), 86 | }, 87 | State::Terminating => { 88 | // Exit loop 89 | break; 90 | } 91 | } 92 | } 93 | match worker_thread.join() { 94 | JoinedThread::Terminated(TerminatedThread { 95 | context: 96 | Context { 97 | progress_hint_rx: _, 98 | worker, 99 | environment: _, 100 | }, 101 | result, 102 | }) => { 103 | result?; 104 | assert_eq!(1, worker.start_working_invocations); 105 | assert_eq!(1, worker.finish_working_invocations); 106 | assert_eq!( 107 | expected_perform_work_invocations, 108 | worker.actual_perform_work_invocations 109 | ); 110 | assert_eq!(expected_perform_work_invocations, resume_accepted + 1,); 111 | } 112 | JoinedThread::JoinError(err) => { 113 | return Err(anyhow::anyhow!("Failed to join worker thread: {:?}", err)) 114 | } 115 | } 116 | } 117 | 118 | Ok(()) 119 | } 120 | 121 | // Start in suspended state and finish immediately while suspended. 122 | #[test] 123 | fn suspend_before_starting_and_finish_while_suspended() -> anyhow::Result<()> { 124 | // 0 => perform_work() must never be invoked with ProgressHint::Continue 125 | let expected_perform_work_invocations = 0; 126 | let worker = SmokeTestWorker::new(expected_perform_work_invocations); 127 | let progress_hint_rx = ProgressHintReceiver::default(); 128 | let progress_hint_tx = ProgressHintSender::attach(&progress_hint_rx); 129 | assert!(matches!( 130 | progress_hint_tx.suspend(), 131 | Ok(SwitchProgressHintOk::Accepted { 132 | previous_state: ProgressHint::Continue, 133 | }) 134 | )); 135 | let context = Context { 136 | progress_hint_rx, 137 | worker, 138 | environment: SmokeTestEnvironment, 139 | }; 140 | // Real-time thread scheduling might not be supported when running the tests 141 | // in containers on CI platforms. 142 | let worker_thread = WorkerThread::spawn(context, ThreadScheduling::Default); 143 | assert_eq!(State::Suspending, worker_thread.wait_until_not_running()); 144 | assert!(matches!( 145 | progress_hint_tx.finish(), 146 | Ok(SwitchProgressHintOk::Accepted { 147 | previous_state: ProgressHint::Suspend, 148 | }) 149 | )); 150 | match worker_thread.join() { 151 | JoinedThread::Terminated(TerminatedThread { 152 | context: 153 | Context { 154 | progress_hint_rx: _, 155 | worker, 156 | environment: _, 157 | }, 158 | result, 159 | }) => { 160 | result?; 161 | assert_eq!(1, worker.start_working_invocations); 162 | assert_eq!(1, worker.finish_working_invocations); 163 | // Two invocations of perform_work() are expected: 164 | // - 1st: ProgressHint::Suspend 165 | // - 2nd: ProgressHint::Finish 166 | assert_eq!(2, worker.actual_perform_work_invocations); 167 | } 168 | JoinedThread::JoinError(err) => { 169 | return Err(anyhow::anyhow!("Failed to join worker thread: {:?}", err)) 170 | } 171 | } 172 | 173 | Ok(()) 174 | } 175 | -------------------------------------------------------------------------------- /crates/msr-core/src/register/mod.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | use crate::{time::SystemInstant, Measurement}; 4 | 5 | #[cfg(feature = "register-recorder")] 6 | pub mod recorder; 7 | 8 | /// Address of a register 9 | /// 10 | /// Each register is addressed by a uniform, 64-bit unsigned integer value. 11 | pub type IndexValue = u64; 12 | 13 | /// Newtype for addressing a single register 14 | #[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash)] 15 | pub struct Index(IndexValue); 16 | 17 | impl Index { 18 | #[must_use] 19 | pub const fn new(value: IndexValue) -> Self { 20 | Self(value) 21 | } 22 | 23 | #[must_use] 24 | pub const fn to_value(self) -> IndexValue { 25 | let Index(value) = self; 26 | value 27 | } 28 | } 29 | 30 | impl From for Index { 31 | fn from(from: IndexValue) -> Self { 32 | Self::new(from) 33 | } 34 | } 35 | 36 | impl From for IndexValue { 37 | fn from(from: Index) -> Self { 38 | from.to_value() 39 | } 40 | } 41 | 42 | impl fmt::Display for Index { 43 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 44 | // Prefixed with NUMBER SIGN, HASHTAG 45 | write!(f, "#{}", self.to_value()) 46 | } 47 | } 48 | 49 | /// Measurement of a single register 50 | #[derive(Debug, Clone, Eq, PartialEq)] 51 | pub struct IndexedMeasurement { 52 | pub index: Index, 53 | pub measurement: Measurement, 54 | } 55 | 56 | /// An observation of a single register value 57 | #[derive(Debug, Clone, Eq, PartialEq)] 58 | pub struct ObservedValue { 59 | pub observed_at: SystemInstant, 60 | pub value: Value, 61 | } 62 | 63 | /// A partial observation of multiple register values 64 | /// 65 | /// The indexes of the registers are implicitly defined by their 66 | /// order, i.e. the mapping to a register index is defined in the 67 | /// outer context. 68 | #[derive(Debug, Clone, Eq, PartialEq)] 69 | pub struct ObservedValues { 70 | pub observed_at: SystemInstant, 71 | pub values: Vec>, 72 | } 73 | -------------------------------------------------------------------------------- /crates/msr-core/src/storage/field.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | use crate::ScalarType; 4 | 5 | #[derive(Debug, Clone, PartialEq, Eq)] 6 | pub struct ScalarField { 7 | pub name: String, 8 | pub unit: Option, 9 | pub r#type: Option, 10 | } 11 | 12 | const FIELD_UNIT_PREFIX: &str = "["; 13 | const FIELD_UNIT_SUFFIX: &str = "]"; 14 | const FIELD_TYPE_SEPARATOR: &str = "."; 15 | 16 | impl fmt::Display for ScalarField { 17 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 18 | let Self { name, unit, r#type } = self; 19 | debug_assert!( 20 | r#type.is_none() || !r#type.unwrap().to_string().contains(FIELD_TYPE_SEPARATOR) 21 | ); 22 | match (unit, r#type) { 23 | // "" 24 | (None, None) => f.write_str(name), 25 | // "[]" 26 | (Some(unit), None) => write!(f, "{name}{FIELD_UNIT_PREFIX}{unit}{FIELD_UNIT_SUFFIX}"), 27 | // "." 28 | (None, Some(r#type)) => write!(f, "{name}{FIELD_TYPE_SEPARATOR}{type}"), 29 | // "[]." 30 | (Some(unit), Some(r#type)) => write!( 31 | f, 32 | "{name}{FIELD_UNIT_PREFIX}{unit}{FIELD_UNIT_SUFFIX}{FIELD_TYPE_SEPARATOR}{type}" 33 | ), 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /crates/msr-core/src/sync/atomic.rs: -------------------------------------------------------------------------------- 1 | #[cfg(loom)] 2 | #[allow(unused_imports)] 3 | pub(crate) use loom::sync::atomic::{AtomicBool, AtomicU64, AtomicU8, Ordering}; 4 | 5 | #[cfg(not(loom))] 6 | #[allow(unused_imports)] 7 | pub(crate) use std::sync::atomic::{AtomicBool, AtomicU64, AtomicU8, Ordering}; 8 | 9 | /// An atomic flag 10 | /// 11 | /// Uses acquire/release memory ordering semantics for 12 | /// reliable handover. 13 | #[derive(Debug, Default)] 14 | pub struct OrderedAtomicFlag(AtomicBool); 15 | 16 | impl OrderedAtomicFlag { 17 | pub fn reset(&self) { 18 | self.0.store(false, Ordering::Release); 19 | } 20 | 21 | pub fn set(&self) { 22 | self.0.store(true, Ordering::Release); 23 | } 24 | 25 | pub fn check_and_reset(&self) -> bool { 26 | // If the CAS operation fails then the current value must have 27 | // been `false`. The ordering on failure is irrelevant since 28 | // the resulting value is discarded. 29 | self.0 30 | .compare_exchange(true, false, Ordering::AcqRel, Ordering::Relaxed) 31 | .is_ok() 32 | } 33 | 34 | pub fn peek(&self) -> bool { 35 | self.0.load(Ordering::Relaxed) 36 | } 37 | 38 | pub fn load(&self) -> bool { 39 | self.0.load(Ordering::Acquire) 40 | } 41 | } 42 | 43 | /// The observed effect of switching the progress hint 44 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 45 | pub enum SwitchAtomicStateOk { 46 | Accepted { 47 | previous_state: T, 48 | }, 49 | 50 | /// Unchanged, i.e. already as desired 51 | Ignored, 52 | } 53 | 54 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 55 | pub enum SwitchAtomicStateErr { 56 | Rejected { current_state: T }, 57 | } 58 | 59 | pub type SwitchAtomicStateResult = Result, SwitchAtomicStateErr>; 60 | 61 | /// Atomic operations for state transitions 62 | /// 63 | /// Needed for implementing state machines with atomic state transitions. 64 | pub trait AtomicState { 65 | type State: Copy; 66 | 67 | /// Peek the current state 68 | /// 69 | /// Uses relaxed memory ordering semantics. 70 | fn peek(&self) -> Self::State; 71 | 72 | /// Load the current state 73 | /// 74 | /// Uses the same memory ordering semantics as when switching 75 | /// the state. 76 | fn load(&self) -> Self::State; 77 | 78 | /// Switch to the desired state unconditionally 79 | /// 80 | /// Replaces the current state with the desired state independent 81 | /// of the current state and returns the previous state. 82 | fn switch_to_desired(&self, desired_state: Self::State) -> SwitchAtomicStateOk; 83 | 84 | /// Switch to the desired state conditionally 85 | /// 86 | /// Replaces the current state with the desired state if it equals 87 | /// the given expected state and returns the previous state. Otherwise 88 | /// returns the unmodified current state. 89 | fn switch_from_expected_to_desired( 90 | &self, 91 | expected_state: Self::State, 92 | desired_state: Self::State, 93 | ) -> SwitchAtomicStateResult; 94 | } 95 | -------------------------------------------------------------------------------- /crates/msr-core/src/sync/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod atomic; 2 | 3 | pub mod relay; 4 | pub use self::relay::Relay; 5 | 6 | // loom doesn't provide a drop-in replacement for std::sync::Weak, 7 | // only for std::sync::Arc. Unfortunately, both are needed. 8 | #[allow(unused_imports)] 9 | pub(crate) use std::sync::{Arc, Weak}; 10 | 11 | #[cfg(loom)] 12 | #[allow(unused_imports)] 13 | pub(crate) use loom::sync::{Condvar, Mutex}; 14 | 15 | #[cfg(not(loom))] 16 | #[allow(unused_imports)] 17 | pub(crate) use std::sync::{Condvar, Mutex}; 18 | -------------------------------------------------------------------------------- /crates/msr-core/src/sync/relay/mod.rs: -------------------------------------------------------------------------------- 1 | use std::time::{Duration, Instant}; 2 | 3 | use crate::sync::{Condvar, Mutex}; 4 | 5 | /// Move single values between threads 6 | /// 7 | /// A condition variable with a single slot that allows to pass 8 | /// values from producer to consumer threads. Producers and consumers 9 | /// may arrive at any point in time. 10 | /// 11 | /// A typical scenario involves only a single producer and a single 12 | /// consumer thread implementing a handover protocol for passing 13 | /// the latest (= most recent) value between each other. 14 | /// 15 | /// The value is buffered until the consumer is ready to take it. 16 | /// Each value can be consumed at most once. Producers can replace 17 | /// the current value if it has not been consumed yet. 18 | #[derive(Debug)] 19 | pub struct Relay { 20 | mutex: Mutex>, 21 | condvar: Condvar, 22 | } 23 | 24 | impl Relay { 25 | #[must_use] 26 | #[cfg(not(loom))] 27 | pub const fn new() -> Self { 28 | Self { 29 | mutex: Mutex::new(None), 30 | condvar: Condvar::new(), 31 | } 32 | } 33 | 34 | #[must_use] 35 | #[cfg(loom)] 36 | pub fn new() -> Self { 37 | Self { 38 | mutex: Mutex::new(None), 39 | condvar: Condvar::new(), 40 | } 41 | } 42 | 43 | #[must_use] 44 | #[cfg(not(loom))] 45 | pub const fn with_value(value: T) -> Self { 46 | Self { 47 | mutex: Mutex::new(Some(value)), 48 | condvar: Condvar::new(), 49 | } 50 | } 51 | 52 | #[must_use] 53 | #[cfg(loom)] 54 | pub fn with_value(value: T) -> Self { 55 | Self { 56 | mutex: Mutex::new(Some(value)), 57 | condvar: Condvar::new(), 58 | } 59 | } 60 | } 61 | 62 | impl Default for Relay { 63 | fn default() -> Self { 64 | Self::new() 65 | } 66 | } 67 | 68 | impl Relay { 69 | /// Replace the current value and notify a single waiting consumer 70 | /// 71 | /// Returns the previous value or `None`. If `None` is returned 72 | /// then a notification has been triggered. 73 | #[allow(clippy::missing_panics_doc)] 74 | pub fn replace_notify_one(&self, value: T) -> Option { 75 | let mut guard = self.mutex.lock().expect("not poisoned"); 76 | let replaced = guard.replace(value); 77 | // Dropping the guard before notifying consumers might 78 | // cause spurious wakeups. These are handled appropriately. 79 | drop(guard); 80 | // Only notify consumers on an edge trigger (None -> Some) 81 | // and not again after subsequent placements (Some -> Some)! 82 | if replaced.is_none() { 83 | self.condvar.notify_one(); 84 | } 85 | replaced 86 | } 87 | 88 | /// Replace the current value and notify all waiting consumers 89 | /// 90 | /// Returns the previous value or `None`. If `None` is returned 91 | /// then a notification has been triggered. 92 | #[allow(clippy::missing_panics_doc)] 93 | pub fn replace_notify_all(&self, value: T) -> Option { 94 | let mut guard = self.mutex.lock().expect("not poisoned"); 95 | let replaced = guard.replace(value); 96 | // Dropping the guard before notifying consumers might 97 | // cause spurious wakeups. These are handled appropriately. 98 | drop(guard); 99 | // Only notify consumers on an edge trigger (None -> Some) 100 | // and not again after subsequent placements (Some -> Some)! 101 | if replaced.is_none() { 102 | self.condvar.notify_all(); 103 | } 104 | replaced 105 | } 106 | 107 | /// Take the current value immediately 108 | /// 109 | /// Resets the internal state on return. 110 | /// 111 | /// Returns the previous value or `None`. 112 | #[allow(clippy::missing_panics_doc)] 113 | pub fn take(&self) -> Option { 114 | let mut guard = self.mutex.lock().expect("not poisoned"); 115 | guard.take() 116 | } 117 | 118 | /// Wait for a value and then take it 119 | /// 120 | /// Resets the internal state on return. 121 | /// 122 | /// Returns the previous value. 123 | #[allow(clippy::missing_panics_doc)] 124 | pub fn wait(&self) -> T { 125 | let mut guard = self.mutex.lock().expect("not poisoned"); 126 | // The loop is required to handle spurious wakeups 127 | loop { 128 | if let Some(value) = guard.take() { 129 | return value; 130 | } 131 | guard = self.condvar.wait(guard).expect("not poisoned"); 132 | } 133 | } 134 | 135 | /// Wait for a value with a timeout and then take it 136 | /// 137 | /// Resets the internal state on return, i.e. either takes the value 138 | /// or on timeout the internal value already was `None` and doesn't 139 | /// need to be reset. 140 | /// 141 | /// Returns the value if available or `None` if the timeout expired. 142 | pub fn wait_for(&self, timeout: Duration) -> Option { 143 | // Handle edge case separately 144 | if timeout.is_zero() { 145 | return self.take(); 146 | } 147 | // Handling spurious timeouts in a loop would require to adjust the 148 | // timeout on each turn by calculating the remaining timeout from 149 | // the elapsed timeout! This is tedious, error prone, and could cause 150 | // jitter when done wrong. Better delegate this task to the 151 | // deadline-constrained wait function. 152 | if let Some(deadline) = Instant::now().checked_add(timeout) { 153 | self.wait_until(deadline) 154 | } else { 155 | // Wait without a deadline if the result cannot be represented 156 | // by an Instant 157 | Some(self.wait()) 158 | } 159 | } 160 | 161 | /// Wait for a value until a deadline and then take it 162 | /// 163 | /// Resets the internal state on return, i.e. either takes the value 164 | /// or on timeout the internal value already was `None` and doesn't 165 | /// need to be reset. 166 | /// 167 | /// Returns the value if available or `None` if the deadline expired. 168 | #[allow(clippy::missing_panics_doc)] 169 | pub fn wait_until(&self, deadline: Instant) -> Option { 170 | let mut guard = self.mutex.lock().expect("not poisoned"); 171 | // The loop is required to handle spurious wakeups 172 | while guard.is_none() { 173 | let now = Instant::now(); 174 | if now >= deadline { 175 | break; 176 | } 177 | let timeout = deadline.duration_since(now); 178 | let (replaced_guard, wait_result) = self 179 | .condvar 180 | .wait_timeout(guard, timeout) 181 | .expect("not poisoned"); 182 | guard = replaced_guard; 183 | if wait_result.timed_out() { 184 | break; 185 | } 186 | // Continue on spurious wakeup 187 | } 188 | guard.take() 189 | } 190 | } 191 | 192 | #[cfg(test)] 193 | mod tests; 194 | -------------------------------------------------------------------------------- /crates/msr-core/src/sync/relay/tests.rs: -------------------------------------------------------------------------------- 1 | use std::time::{Duration, Instant}; 2 | 3 | use crate::sync::Arc; 4 | 5 | use super::*; 6 | 7 | type UnitRelay = Relay<()>; 8 | 9 | const TIMEOUT_FOR_NEXT_NOTIFICATION: Duration = Duration::from_millis(100); 10 | 11 | struct CapturedNotifications { 12 | number_of_notifications: usize, 13 | last_value: Option, 14 | } 15 | 16 | fn capture_notifications_fn( 17 | relay: &Relay, 18 | max_number_of_notifications: usize, 19 | ) -> CapturedNotifications { 20 | let mut number_of_notifications = 0; 21 | let mut last_value = None; 22 | while number_of_notifications < max_number_of_notifications { 23 | let next_value = relay.wait_for(TIMEOUT_FOR_NEXT_NOTIFICATION); 24 | if next_value.is_none() { 25 | // Timed out 26 | break; 27 | } 28 | number_of_notifications += 1; 29 | last_value = next_value; 30 | } 31 | CapturedNotifications { 32 | number_of_notifications, 33 | last_value, 34 | } 35 | } 36 | 37 | #[test] 38 | fn wait_for_timeout_zero_empty() { 39 | let relay = UnitRelay::default(); 40 | 41 | assert!(relay.wait_for(Duration::ZERO).is_none()); 42 | } 43 | 44 | #[test] 45 | fn wait_for_timeout_zero_ready() { 46 | let relay = Relay::default(); 47 | 48 | relay.replace_notify_one(()); 49 | 50 | assert!(relay.wait_for(Duration::ZERO).is_some()); 51 | } 52 | 53 | #[test] 54 | fn wait_for_timeout_max_ready() { 55 | let relay = Relay::default(); 56 | 57 | relay.replace_notify_one(()); 58 | 59 | assert!(relay.wait_for(Duration::MAX).is_some()); 60 | } 61 | 62 | #[test] 63 | fn wait_until_deadline_now_empty() { 64 | let relay = UnitRelay::default(); 65 | 66 | assert!(relay.wait_until(Instant::now()).is_none()); 67 | } 68 | 69 | #[test] 70 | fn wait_until_deadline_now_ready() { 71 | let relay = Relay::default(); 72 | 73 | relay.replace_notify_one(()); 74 | 75 | assert!(relay.wait_until(Instant::now()).is_some()); 76 | } 77 | 78 | #[test] 79 | fn keep_last_value() { 80 | let relay = Relay::default(); 81 | 82 | let rounds = 10; 83 | 84 | for i in 1..=rounds { 85 | relay.replace_notify_one(i); 86 | } 87 | 88 | assert_eq!(Some(rounds), relay.take()); 89 | } 90 | 91 | #[test] 92 | fn capture_notifications_concurrently() { 93 | let relay = Arc::new(Relay::default()); 94 | 95 | let rounds = 10; 96 | 97 | let thread = std::thread::spawn({ 98 | let relay = Arc::clone(&relay); 99 | move || capture_notifications_fn(&relay, rounds) 100 | }); 101 | 102 | for i in 1..=rounds { 103 | relay.replace_notify_one(i); 104 | } 105 | 106 | let CapturedNotifications { 107 | number_of_notifications, 108 | last_value, 109 | } = thread.join().unwrap(); 110 | 111 | assert!(number_of_notifications >= 1); 112 | assert!(number_of_notifications <= rounds); 113 | assert_eq!(Some(rounds), last_value); 114 | } 115 | -------------------------------------------------------------------------------- /crates/msr-core/src/thread.rs: -------------------------------------------------------------------------------- 1 | use std::time::Instant; 2 | 3 | pub use std::thread::*; 4 | 5 | /// Puts the current thread to sleep for at least until the deadline 6 | /// 7 | /// The thread may sleep longer than the deadline specified due to scheduling 8 | /// specifics or platform-dependent functionality. It will never sleep less. 9 | /// 10 | /// See also: 11 | /// 12 | /// TODO: Use [spin-sleep](https://github.com/alexheretic/spin-sleep) depending 13 | /// on the use case for reliable accuracy to limit the maximum jitter? 14 | pub fn sleep_until(deadline: Instant) { 15 | let now = Instant::now(); 16 | if now >= deadline { 17 | return; 18 | } 19 | sleep(deadline.duration_since(now)); 20 | } 21 | -------------------------------------------------------------------------------- /crates/msr-core/src/time.rs: -------------------------------------------------------------------------------- 1 | //! Time related types 2 | 3 | use std::{ 4 | fmt, 5 | ops::{Add, AddAssign, Deref, DerefMut, Sub, SubAssign}, 6 | time::{Duration, Instant, SystemTime}, 7 | }; 8 | 9 | use time::{ 10 | error::IndeterminateOffset, format_description::well_known::Rfc3339, OffsetDateTime, UtcOffset, 11 | }; 12 | 13 | /// A system time with the corresponding instant. 14 | /// 15 | /// This should only be used for anchoring values of Instant 16 | /// for conversion into system time. 17 | #[derive(Debug, Clone, PartialEq, Eq)] 18 | pub struct SystemInstant { 19 | system_time: SystemTime, 20 | instant: Instant, 21 | } 22 | 23 | impl SystemInstant { 24 | #[must_use] 25 | pub const fn new(system_time: SystemTime, instant: Instant) -> Self { 26 | Self { 27 | system_time, 28 | instant, 29 | } 30 | } 31 | 32 | #[must_use] 33 | pub fn now() -> Self { 34 | let system_time = SystemTime::now(); 35 | // Assumption: The current instant obtained right AFTER receiving 36 | // the current system time denotes the same point in time and any 37 | // difference between them is negligible. 38 | let instant = Instant::now(); 39 | Self::new(system_time, instant) 40 | } 41 | 42 | #[must_use] 43 | pub fn system_time(&self) -> SystemTime { 44 | self.system_time 45 | } 46 | 47 | #[must_use] 48 | pub fn instant(&self) -> Instant { 49 | self.instant 50 | } 51 | 52 | #[must_use] 53 | pub fn timestamp_utc(&self) -> Timestamp { 54 | TimestampInner::from(self.system_time).into() 55 | } 56 | 57 | #[must_use] 58 | pub fn checked_duration_since_instant(&self, since_instant: Instant) -> Option { 59 | self.instant.checked_duration_since(since_instant) 60 | } 61 | 62 | #[must_use] 63 | pub fn checked_duration_until_instant(&self, until_instant: Instant) -> Option { 64 | until_instant.checked_duration_since(self.instant) 65 | } 66 | 67 | #[must_use] 68 | pub fn checked_system_time_for_instant(&self, instant: Instant) -> Option { 69 | if self.instant < instant { 70 | self.system_time 71 | .checked_add(instant.duration_since(self.instant)) 72 | } else { 73 | self.system_time 74 | .checked_sub(self.instant.duration_since(instant)) 75 | } 76 | } 77 | } 78 | 79 | impl Add for SystemInstant { 80 | type Output = Self; 81 | 82 | fn add(self, rhs: Duration) -> Self { 83 | let Self { 84 | system_time, 85 | instant, 86 | } = self; 87 | Self::new(system_time + rhs, instant + rhs) 88 | } 89 | } 90 | 91 | impl AddAssign for SystemInstant { 92 | fn add_assign(&mut self, rhs: Duration) { 93 | let Self { 94 | mut system_time, 95 | mut instant, 96 | } = self; 97 | system_time += rhs; 98 | instant += rhs; 99 | } 100 | } 101 | 102 | type TimestampInner = OffsetDateTime; 103 | 104 | /// A timestamp 105 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] 106 | pub struct Timestamp(TimestampInner); 107 | 108 | impl Timestamp { 109 | #[must_use] 110 | pub const fn new(inner: TimestampInner) -> Self { 111 | Self(inner) 112 | } 113 | 114 | #[must_use] 115 | pub const fn to_inner(self) -> TimestampInner { 116 | let Self(inner) = self; 117 | inner 118 | } 119 | 120 | #[must_use] 121 | pub const fn to_utc(self) -> Self { 122 | Self(self.to_inner().to_offset(UtcOffset::UTC)) 123 | } 124 | 125 | /// Current system time with offset 126 | /// 127 | /// Tries to obtain the current system time with the local time zone 128 | /// offset. Returns an UTC timestamp as a fallback if the local time 129 | /// zone is unknown or could not be determined. 130 | /// 131 | /// Prefer to use [`Self::now_utc()`] if the local time zone offset doesn't 132 | /// matter. 133 | #[must_use] 134 | pub fn now() -> Self { 135 | TimestampInner::now_local() 136 | .unwrap_or_else(|_: IndeterminateOffset| TimestampInner::now_utc()) 137 | .into() 138 | } 139 | 140 | /// Current system time (UTC) 141 | #[must_use] 142 | pub fn now_utc() -> Self { 143 | TimestampInner::now_utc().into() 144 | } 145 | 146 | pub fn parse_rfc3339(input: &str) -> Result { 147 | TimestampInner::parse(input, &Rfc3339).map(Self::new) 148 | } 149 | 150 | pub fn format_rfc3339(&self) -> Result { 151 | self.0.format(&Rfc3339) 152 | } 153 | 154 | pub fn format_rfc3339_into( 155 | &self, 156 | output: &mut W, 157 | ) -> Result { 158 | self.0.format_into(output, &Rfc3339) 159 | } 160 | } 161 | 162 | impl From for Timestamp { 163 | fn from(inner: TimestampInner) -> Self { 164 | Self::new(inner) 165 | } 166 | } 167 | 168 | impl From for TimestampInner { 169 | fn from(from: Timestamp) -> Self { 170 | from.to_inner() 171 | } 172 | } 173 | 174 | impl From for Timestamp { 175 | fn from(system_time: SystemTime) -> Self { 176 | Self::new(system_time.into()) 177 | } 178 | } 179 | 180 | impl From for SystemTime { 181 | fn from(from: Timestamp) -> Self { 182 | from.to_inner().into() 183 | } 184 | } 185 | 186 | impl AsRef for Timestamp { 187 | fn as_ref(&self) -> &TimestampInner { 188 | &self.0 189 | } 190 | } 191 | 192 | impl Deref for Timestamp { 193 | type Target = TimestampInner; 194 | 195 | fn deref(&self) -> &TimestampInner { 196 | self.as_ref() 197 | } 198 | } 199 | 200 | impl DerefMut for Timestamp { 201 | fn deref_mut(&mut self) -> &mut ::Target { 202 | let Self(inner) = self; 203 | inner 204 | } 205 | } 206 | 207 | impl fmt::Display for Timestamp { 208 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 209 | self.as_ref().fmt(f) 210 | } 211 | } 212 | 213 | #[cfg(feature = "serde")] 214 | impl serde::Serialize for Timestamp { 215 | fn serialize(&self, serializer: S) -> Result 216 | where 217 | S: serde::Serializer, 218 | { 219 | time::serde::rfc3339::serialize(self.as_ref(), serializer) 220 | } 221 | } 222 | 223 | #[cfg(feature = "serde")] 224 | impl<'de> serde::Deserialize<'de> for Timestamp { 225 | fn deserialize(deserializer: D) -> Result 226 | where 227 | D: serde::Deserializer<'de>, 228 | { 229 | time::serde::rfc3339::deserialize(deserializer).map(Self::new) 230 | } 231 | } 232 | 233 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 234 | pub enum Interval { 235 | Nanos(u32), 236 | Micros(u32), 237 | Millis(u32), 238 | Seconds(u32), 239 | Minutes(u32), 240 | Hours(u32), 241 | Days(u32), 242 | Weeks(u32), 243 | } 244 | 245 | impl Interval { 246 | fn as_duration(self) -> Duration { 247 | match self { 248 | Self::Nanos(nanos) => Duration::from_nanos(u64::from(nanos)), 249 | Self::Micros(micros) => Duration::from_micros(u64::from(micros)), 250 | Self::Millis(millis) => Duration::from_millis(u64::from(millis)), 251 | Self::Seconds(secs) => Duration::from_secs(u64::from(secs)), 252 | Self::Minutes(mins) => Duration::from_secs(u64::from(mins) * 60), 253 | Self::Hours(hrs) => Duration::from_secs(u64::from(hrs) * 60 * 60), 254 | Self::Days(days) => Duration::from_secs(u64::from(days) * 60 * 60 * 24), 255 | Self::Weeks(weeks) => Duration::from_secs(u64::from(weeks) * 60 * 60 * 24 * 7), 256 | } 257 | } 258 | 259 | #[must_use] 260 | pub fn system_time_before(&self, system_time: SystemTime) -> SystemTime { 261 | system_time - self.as_duration() 262 | } 263 | 264 | #[must_use] 265 | pub fn system_time_after(&self, system_time: SystemTime) -> SystemTime { 266 | system_time + self.as_duration() 267 | } 268 | } 269 | 270 | impl Add for Timestamp { 271 | type Output = Timestamp; 272 | 273 | fn add(self, interval: Interval) -> Self::Output { 274 | (self.to_inner() + interval.as_duration()).into() 275 | } 276 | } 277 | 278 | impl AddAssign for Timestamp { 279 | fn add_assign(&mut self, interval: Interval) { 280 | *self = *self + interval; 281 | } 282 | } 283 | 284 | impl Sub for Timestamp { 285 | type Output = Timestamp; 286 | 287 | fn sub(self, interval: Interval) -> Self::Output { 288 | (self.to_inner() - interval.as_duration()).into() 289 | } 290 | } 291 | 292 | impl SubAssign for Timestamp { 293 | fn sub_assign(&mut self, interval: Interval) { 294 | *self = *self - interval; 295 | } 296 | } 297 | -------------------------------------------------------------------------------- /crates/msr-core/src/value/mod.rs: -------------------------------------------------------------------------------- 1 | use std::{fmt, time::Duration}; 2 | 3 | // TODO: Make `scalar` module public instead of renaming and re-exporting all types? 4 | mod scalar; 5 | pub use self::scalar::{Type as ScalarType, Value as ScalarValue}; 6 | 7 | pub trait ToValueType { 8 | fn to_value_type(&self) -> ValueType; 9 | } 10 | 11 | /// Enumeration of value types 12 | #[derive(Debug, Copy, Clone, PartialEq, Eq)] 13 | pub enum ValueType { 14 | /// Scalar type 15 | Scalar(ScalarType), 16 | 17 | /// Time duration, e.g. a timeout 18 | Duration, 19 | 20 | /// Text data 21 | String, 22 | 23 | /// Binary data 24 | Bytes, 25 | } 26 | 27 | // TODO: Use short identifiers? 28 | const TYPE_STR_DURATION: &str = "duration"; 29 | const TYPE_STR_STRING: &str = "string"; 30 | const TYPE_STR_BYTES: &str = "bytes"; 31 | 32 | impl ValueType { 33 | #[must_use] 34 | pub const fn to_scalar(self) -> Option { 35 | match self { 36 | Self::Scalar(s) => Some(s), 37 | _ => None, 38 | } 39 | } 40 | 41 | #[must_use] 42 | pub const fn is_scalar(self) -> bool { 43 | self.to_scalar().is_some() 44 | } 45 | 46 | #[must_use] 47 | pub const fn from_scalar(scalar: ScalarType) -> Self { 48 | Self::Scalar(scalar) 49 | } 50 | 51 | const fn as_str(self) -> &'static str { 52 | match self { 53 | Self::Scalar(s) => s.as_str(), 54 | Self::Duration => TYPE_STR_DURATION, 55 | Self::String => TYPE_STR_STRING, 56 | Self::Bytes => TYPE_STR_BYTES, 57 | } 58 | } 59 | 60 | #[must_use] 61 | pub fn try_from_str(s: &str) -> Option { 62 | ScalarType::try_from_str(s).map(Into::into).or(match s { 63 | TYPE_STR_DURATION => Some(Self::Duration), 64 | TYPE_STR_STRING => Some(Self::String), 65 | TYPE_STR_BYTES => Some(Self::Bytes), 66 | _ => None, 67 | }) 68 | } 69 | } 70 | 71 | impl From for ValueType { 72 | fn from(from: ScalarType) -> Self { 73 | Self::from_scalar(from) 74 | } 75 | } 76 | 77 | impl fmt::Display for ValueType { 78 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 79 | f.write_str(self.as_str()) 80 | } 81 | } 82 | 83 | /// A value representation within a MSR system. 84 | /// 85 | /// TODO: Split into a separate type for simple, copyable values and 86 | /// an enclosing type that includes the complex, non-real-time-safe 87 | /// values? 88 | #[derive(Debug, Clone, PartialEq)] 89 | pub enum Value { 90 | /// Scalar value (real-time safe) 91 | /// 92 | /// This variant can safely be used in real-time contexts. 93 | Scalar(ScalarValue), 94 | 95 | /// Duration, e.g. a timeout 96 | /// 97 | /// This variant can safely be used in real-time contexts. 98 | Duration(Duration), 99 | 100 | /// Variable-size text data 101 | /// 102 | /// This variant must not be used in real-time contexts. 103 | String(String), 104 | 105 | /// Variable-size binary data 106 | /// 107 | /// This variant must not be used in real-time contexts. 108 | Bytes(Vec), 109 | } 110 | 111 | impl From for Value { 112 | fn from(from: Duration) -> Value { 113 | Self::Duration(from) 114 | } 115 | } 116 | 117 | impl From for Value { 118 | fn from(from: String) -> Value { 119 | Self::String(from) 120 | } 121 | } 122 | 123 | impl From> for Value { 124 | fn from(from: Vec) -> Value { 125 | Self::Bytes(from) 126 | } 127 | } 128 | 129 | impl Value { 130 | #[must_use] 131 | pub const fn to_type(&self) -> ValueType { 132 | match self { 133 | Self::Scalar(value) => ValueType::Scalar(value.to_type()), 134 | Self::Duration(_) => ValueType::Duration, 135 | Self::String(_) => ValueType::String, 136 | Self::Bytes(_) => ValueType::Bytes, 137 | } 138 | } 139 | 140 | #[must_use] 141 | pub const fn to_scalar(&self) -> Option { 142 | match self { 143 | Self::Scalar(scalar) => Some(*scalar), 144 | _ => None, 145 | } 146 | } 147 | 148 | #[must_use] 149 | pub const fn from_scalar(scalar: ScalarValue) -> Self { 150 | Self::Scalar(scalar) 151 | } 152 | 153 | pub fn to_i32(&self) -> Option { 154 | self.to_scalar().and_then(ScalarValue::to_i32) 155 | } 156 | 157 | pub fn to_u32(&self) -> Option { 158 | self.to_scalar().and_then(ScalarValue::to_u32) 159 | } 160 | 161 | pub fn to_i64(&self) -> Option { 162 | self.to_scalar().and_then(ScalarValue::to_i64) 163 | } 164 | 165 | pub fn to_u64(&self) -> Option { 166 | self.to_scalar().and_then(ScalarValue::to_u64) 167 | } 168 | 169 | pub fn to_f32(&self) -> Option { 170 | self.to_scalar().and_then(ScalarValue::to_f32) 171 | } 172 | 173 | pub fn to_f64(&self) -> Option { 174 | self.to_scalar().and_then(ScalarValue::to_f64) 175 | } 176 | } 177 | 178 | impl ToValueType for Value { 179 | fn to_value_type(&self) -> ValueType { 180 | self.to_type() 181 | } 182 | } 183 | 184 | impl From for Value 185 | where 186 | S: Into, 187 | { 188 | fn from(from: S) -> Self { 189 | Value::Scalar(from.into()) 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /crates/msr-core/src/value/scalar/tests.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn value_types() { 5 | assert_eq!(Type::Bool, Value::from(true).to_type()); 6 | assert_eq!(Type::Bool, Value::from(false).to_type()); 7 | assert_eq!(Type::I8, Value::from(-123i8).to_type()); 8 | assert_eq!(Type::U8, Value::from(123u8).to_type()); 9 | assert_eq!(Type::I16, Value::from(-123i16).to_type()); 10 | assert_eq!(Type::U16, Value::from(123u16).to_type()); 11 | assert_eq!(Type::I32, Value::from(-123i32).to_type()); 12 | assert_eq!(Type::U32, Value::from(123u32).to_type()); 13 | assert_eq!(Type::F32, Value::from(1.234_f32).to_type()); 14 | assert_eq!(Type::I64, Value::from(-123i64).to_type()); 15 | assert_eq!(Type::U64, Value::from(123u64).to_type()); 16 | assert_eq!(Type::F64, Value::from(1.234).to_type()); 17 | } 18 | 19 | #[test] 20 | fn try_type_from_str() { 21 | assert_eq!(Some(Type::Bool), Type::try_from_str(TYPE_STR_BOOL)); 22 | assert_eq!(Some(Type::I8), Type::try_from_str(TYPE_STR_I8)); 23 | assert_eq!(Some(Type::U8), Type::try_from_str(TYPE_STR_U8)); 24 | assert_eq!(Some(Type::I16), Type::try_from_str(TYPE_STR_I16)); 25 | assert_eq!(Some(Type::U16), Type::try_from_str(TYPE_STR_U16)); 26 | assert_eq!(Some(Type::I32), Type::try_from_str(TYPE_STR_I32)); 27 | assert_eq!(Some(Type::U32), Type::try_from_str(TYPE_STR_U32)); 28 | assert_eq!(Some(Type::F32), Type::try_from_str(TYPE_STR_F32)); 29 | assert_eq!(Some(Type::I64), Type::try_from_str(TYPE_STR_I64)); 30 | assert_eq!(Some(Type::U64), Type::try_from_str(TYPE_STR_U64)); 31 | assert_eq!(Some(Type::F64), Type::try_from_str(TYPE_STR_F64)); 32 | } 33 | -------------------------------------------------------------------------------- /crates/msr-legacy/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | version = "0.2.0" 3 | name = "msr-legacy" 4 | description = "Industrial Automation Toolbox - Legacy" 5 | license = "MIT/Apache-2.0" 6 | edition = "2021" 7 | # Reserved for internal use and migration purposes 8 | publish = false 9 | 10 | [dependencies] 11 | serde = { version = "1.0.188", features = ["derive"], optional = true } 12 | 13 | [features] 14 | default = ["serde"] 15 | 16 | [dev-dependencies] 17 | serde_json = "1.0.105" 18 | -------------------------------------------------------------------------------- /crates/msr-legacy/src/bang_bang.rs: -------------------------------------------------------------------------------- 1 | //! # Example 2 | //! ```rust,no_run 3 | //! use msr_legacy::{Controller, bang_bang::*}; 4 | //! 5 | //! let cfg = BangBangConfig { 6 | //! default_threshold: 5.8, 7 | //! hysteresis: 0.1, 8 | //! }; 9 | //! let mut c = BangBang::new(cfg); 10 | //! 11 | //! assert!(!c.next(5.89)); // 5.89 < threshold + hysteresis 12 | //! assert!(c.next(5.9)); 13 | //! assert!(c.next(5.89)); // 5.89 > threshold - hysteresis 14 | //! assert!(c.next(5.71)); 15 | //! assert!(!c.next(5.69)); 16 | //! ``` 17 | 18 | use super::{Controller, PureController}; 19 | 20 | /// A Bang-bang controller implementation 21 | #[derive(Debug, Clone)] 22 | pub struct BangBang { 23 | cfg: BangBangConfig, 24 | state: BangBangState, 25 | } 26 | 27 | /// Bang-bang controller configuration 28 | #[derive(Debug, Clone, PartialEq)] 29 | #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 30 | pub struct BangBangConfig { 31 | pub default_threshold: f64, 32 | pub hysteresis: f64, 33 | } 34 | 35 | #[derive(Debug, Clone, Copy, PartialEq)] 36 | pub struct BangBangState { 37 | pub current: bool, 38 | pub threshold: f64, 39 | } 40 | 41 | impl Default for BangBangState { 42 | fn default() -> Self { 43 | BangBangState { 44 | current: false, 45 | threshold: 0.0, 46 | } 47 | } 48 | } 49 | 50 | impl Default for BangBangConfig { 51 | fn default() -> Self { 52 | BangBangConfig { 53 | default_threshold: 0.0, 54 | hysteresis: 0.0, 55 | } 56 | } 57 | } 58 | 59 | impl BangBang { 60 | /// Create a new controller instance with the given configuration. 61 | pub fn new(cfg: BangBangConfig) -> Self { 62 | let state = BangBangState { 63 | threshold: cfg.default_threshold, 64 | ..Default::default() 65 | }; 66 | BangBang { cfg, state } 67 | } 68 | } 69 | 70 | impl Controller for BangBang { 71 | fn next(&mut self, actual: f64) -> bool { 72 | self.state = self.cfg.next((self.state, actual)); 73 | self.state.current 74 | } 75 | } 76 | 77 | impl PureController<(BangBangState, f64), BangBangState> for BangBangConfig { 78 | fn next(&self, input: (BangBangState, f64)) -> BangBangState { 79 | let (mut state, actual) = input; 80 | if actual > state.threshold + self.hysteresis { 81 | state.current = true; 82 | } else if actual < state.threshold - self.hysteresis { 83 | state.current = false; 84 | } 85 | state 86 | } 87 | } 88 | 89 | #[cfg(test)] 90 | #[allow(clippy::float_cmp)] 91 | mod tests { 92 | 93 | use super::*; 94 | 95 | use std::f64::{INFINITY, NAN, NEG_INFINITY}; 96 | 97 | #[test] 98 | fn default_bang_bang_config() { 99 | let cfg = BangBangConfig::default(); 100 | assert_eq!(cfg.default_threshold, 0.0); 101 | assert_eq!(cfg.hysteresis, 0.0); 102 | } 103 | 104 | #[test] 105 | fn calculate_with_default_cfg() { 106 | let mut bb = BangBang::new(BangBangConfig::default()); 107 | assert!(bb.next(0.1)); 108 | assert!(bb.next(0.0)); 109 | assert!(!bb.next(-0.1)); 110 | assert!(!bb.next(0.0)); 111 | } 112 | 113 | #[test] 114 | fn calculate_with_custom_threshold() { 115 | let cfg = BangBangConfig { 116 | default_threshold: 3.3, 117 | ..Default::default() 118 | }; 119 | let mut bb = BangBang::new(cfg); 120 | assert!(!bb.next(1.0)); 121 | assert!(!bb.next(3.3)); 122 | assert!(bb.next(3.4)); 123 | assert!(bb.next(3.3)); 124 | assert!(!bb.next(3.2)); 125 | } 126 | 127 | #[test] 128 | fn calculate_with_hysteresis() { 129 | let cfg = BangBangConfig { 130 | hysteresis: 0.5, 131 | ..Default::default() 132 | }; 133 | let mut bb = BangBang::new(cfg); 134 | let states = vec![ 135 | (0.0, false), 136 | (0.5, false), 137 | (0.6, true), 138 | (0.5, true), 139 | (0.0, true), 140 | (-0.5, true), 141 | (-0.6, false), 142 | (0.0, false), 143 | (0.5, false), 144 | (0.6, true), 145 | ]; 146 | 147 | for (input, output) in states { 148 | assert_eq!(bb.next(input), output); 149 | } 150 | } 151 | 152 | #[test] 153 | fn calculate_with_infinity_input() { 154 | let cfg = BangBangConfig::default(); 155 | let mut bb = BangBang::new(cfg); 156 | assert!(bb.next(INFINITY)); 157 | assert!(bb.next(0.0)); 158 | assert!(!bb.next(NEG_INFINITY)); 159 | } 160 | 161 | #[test] 162 | fn calculate_with_infinity_threshold() { 163 | let cfg = BangBangConfig { 164 | default_threshold: INFINITY, 165 | ..Default::default() 166 | }; 167 | let mut bb = BangBang::new(cfg); 168 | assert!(!bb.next(INFINITY * 2.0)); 169 | 170 | let cfg = BangBangConfig { 171 | default_threshold: NEG_INFINITY, 172 | ..Default::default() 173 | }; 174 | let mut bb = BangBang::new(cfg); 175 | assert!(!bb.next(NEG_INFINITY * 2.0)); 176 | } 177 | 178 | #[test] 179 | fn ignore_nan_input() { 180 | let cfg = BangBangConfig { 181 | hysteresis: 0.5, 182 | ..Default::default() 183 | }; 184 | let mut bb = BangBang::new(cfg); 185 | assert!(bb.next(0.6)); 186 | assert!(bb.next(NAN)); 187 | assert!(bb.next(-0.49)); 188 | assert!(!bb.next(-0.6)); 189 | assert!(!bb.next(NAN)); 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /crates/msr-legacy/src/entities.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | use std::time::Duration; 3 | 4 | /// I/O (sensor or actuator) 5 | /// 6 | /// # Example 7 | /// ```rust,no_run 8 | /// use msr_legacy::IoGate; 9 | /// 10 | /// let tcr001 = IoGate::new("tcr001".into()); 11 | /// 12 | /// // or create it from a str 13 | /// let tcr002 : IoGate = "tcr002".into(); 14 | /// ``` 15 | #[derive(Debug, Clone, PartialEq)] 16 | pub struct IoGate { 17 | /// The unique ID of the gate 18 | pub id: String, 19 | /// Value mapping 20 | pub mapping: Option, 21 | /// Value cropping 22 | pub cropping: Option, 23 | /// Value calibration 24 | pub calib: Option, 25 | } 26 | 27 | /// Map a number **from** one range **to** another. 28 | /// 29 | /// That is, a value of `from.low` would get mapped to `to.low`, 30 | /// a value of `from.high` to `to.high`, 31 | /// values in-between to values in-between, etc. 32 | // TODO: Make this more generic 33 | #[derive(Debug, Clone, PartialEq)] 34 | #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 35 | pub struct ValueMapping { 36 | /// The bounds of the value’s current range 37 | pub from: ValueBounds, 38 | /// The bounds of the value’s target range 39 | pub to: ValueBounds, 40 | } 41 | 42 | impl ValueMapping { 43 | pub fn map(&self, x: f64) -> f64 { 44 | util::map_value(x, self.from.low, self.from.high, self.to.low, self.to.high) 45 | } 46 | } 47 | 48 | /// Bounds of a value’s range. 49 | #[derive(Debug, Clone, PartialEq)] 50 | #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 51 | pub struct ValueBounds { 52 | /// The lower bound of the value’s range 53 | pub low: f64, 54 | /// The upper bound of the value’s range 55 | pub high: f64, 56 | } 57 | 58 | /// Cropping value 59 | #[derive(Debug, Clone, PartialEq)] 60 | #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 61 | pub struct Cropping { 62 | /// The lower threshold 63 | pub low: Option, 64 | /// The upper threshold 65 | pub high: Option, 66 | } 67 | 68 | impl Cropping { 69 | /// Crop a value 70 | /// 71 | /// e.g. with `low = 2.0` 72 | /// - `1.9` -> `2.0` 73 | /// - `2.0` -> `2.0` 74 | /// 75 | /// with `high = 2.0` 76 | /// - `2.1` -> `2.0` 77 | /// - `1.9` -> `1.9` 78 | pub fn crop(&self, x: f64) -> f64 { 79 | util::limit(self.low, self.high, x) 80 | } 81 | } 82 | 83 | /// Calibration coefficients 84 | #[derive(Debug, Clone, PartialEq)] 85 | #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 86 | pub struct Calibration { 87 | pub a: Option, 88 | pub b: Option, 89 | pub c: Option, 90 | } 91 | 92 | impl IoGate { 93 | pub fn new(id: String) -> Self { 94 | IoGate { 95 | id, 96 | mapping: None, 97 | cropping: None, 98 | calib: None, 99 | } 100 | } 101 | } 102 | 103 | impl<'a> From<&'a str> for IoGate { 104 | fn from(id: &'a str) -> Self { 105 | IoGate::new(id.into()) 106 | } 107 | } 108 | 109 | /// A loop continuously triggers a controller again and again. 110 | #[derive(Debug, Clone)] 111 | pub struct Loop { 112 | /// The unique ID of the rule 113 | pub id: String, 114 | /// Used inputs 115 | pub inputs: Vec, 116 | /// Used outputs 117 | pub outputs: Vec, 118 | /// The controller configuration 119 | pub controller: ControllerConfig, 120 | } 121 | 122 | /// A periodic interval with a fixed duration 123 | #[derive(Debug, Clone)] 124 | pub struct Interval { 125 | /// The unique ID of the interval 126 | pub id: String, 127 | /// The duration between two events 128 | pub duration: Duration, 129 | } 130 | 131 | /// A Rule connects a condition with a list of actions. 132 | #[derive(Debug, Clone, PartialEq)] 133 | pub struct Rule { 134 | /// The unique ID of the rule 135 | pub id: String, 136 | /// The condition 137 | pub condition: BoolExpr, 138 | /// Actions that should be triggered 139 | pub actions: Vec, 140 | } 141 | 142 | /// An action can modify outputs and setpoints. 143 | #[derive(Debug, Clone, PartialEq)] 144 | pub struct Action { 145 | /// The unique ID of the action 146 | pub id: String, 147 | /// Define output values 148 | pub outputs: HashMap, 149 | /// Define memory values 150 | pub memory: HashMap, 151 | /// Define setpoint values 152 | pub setpoints: HashMap, 153 | /// Define controller states 154 | pub controllers: HashMap, 155 | /// Define timeouts 156 | pub timeouts: HashMap>, 157 | } 158 | 159 | /// An action to modify the state or behaviour of a controller. 160 | #[derive(Debug, Clone, PartialEq, Eq)] 161 | pub struct ControllerAction { 162 | /// Reset controlle state 163 | pub reset: bool, 164 | /// Start/Stop a controller 165 | pub active: Option, 166 | } 167 | 168 | #[cfg(test)] 169 | #[allow(clippy::float_cmp)] 170 | mod tests { 171 | 172 | use super::*; 173 | 174 | #[test] 175 | fn map_input() { 176 | let map = ValueMapping { 177 | from: ValueBounds { 178 | low: 4.0, 179 | high: 20.0, 180 | }, 181 | to: ValueBounds { 182 | low: 0.0, 183 | high: 100.0, 184 | }, 185 | }; 186 | assert_eq!(map.map(4.0), 0.0); 187 | assert_eq!(map.map(12.0), 50.0); 188 | assert_eq!(map.map(20.0), 100.0); 189 | } 190 | 191 | #[test] 192 | fn crop_input() { 193 | let cropping = Cropping { 194 | low: Some(2.0), 195 | high: Some(3.0), 196 | }; 197 | assert_eq!(cropping.crop(1.9), 2.0); 198 | assert_eq!(cropping.crop(-1.9), 2.0); 199 | assert_eq!(cropping.crop(2.0), 2.0); 200 | assert_eq!(cropping.crop(2.1), 2.1); 201 | assert_eq!(cropping.crop(3.0), 3.0); 202 | assert_eq!(cropping.crop(3.1), 3.0); 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /crates/msr-legacy/src/fsm.rs: -------------------------------------------------------------------------------- 1 | //! Finit State Machine 2 | use super::*; 3 | 4 | /// Finit State Machine 5 | #[derive(Debug, Clone)] 6 | pub struct StateMachine { 7 | /// Initial state 8 | pub initial: String, 9 | /// Transitions 10 | pub transitions: Vec, 11 | } 12 | 13 | /// A State Transition 14 | #[derive(Debug, Clone)] 15 | pub struct Transition { 16 | pub condition: BoolExpr, 17 | pub from: String, 18 | pub to: String, 19 | pub actions: Vec, 20 | } 21 | 22 | impl<'a> PureController<(Option<&'a str>, &'a SystemState), Option<(String, Vec)>> 23 | for StateMachine 24 | { 25 | fn next(&self, input: (Option<&str>, &SystemState)) -> Option<(String, Vec)> { 26 | let (fsm_state, state) = input; 27 | 28 | for t in &self.transitions { 29 | if t.from == fsm_state.unwrap_or(&self.initial) { 30 | if let Ok(active) = t.condition.eval(state) { 31 | if active { 32 | return Some((t.to.clone(), t.actions.clone())); 33 | } 34 | } 35 | } 36 | } 37 | None 38 | } 39 | } 40 | 41 | #[cfg(test)] 42 | mod tests { 43 | 44 | use super::*; 45 | 46 | #[test] 47 | fn simple_fsm() { 48 | let mut state = SystemState::default(); 49 | let machine = StateMachine { 50 | initial: "start".into(), 51 | transitions: vec![ 52 | Transition { 53 | condition: BoolExpr::Eval( 54 | Source::In("x".into()).cmp_gt(Source::Const(5.0.into())), 55 | ), 56 | from: "start".into(), 57 | to: "step-one".into(), 58 | actions: vec![], 59 | }, 60 | Transition { 61 | condition: BoolExpr::Eval( 62 | Source::In("y".into()).cmp_gt(Source::Const(7.0.into())), 63 | ), 64 | from: "step-one".into(), 65 | to: "step-two".into(), 66 | actions: vec![], 67 | }, 68 | ], 69 | }; 70 | assert_eq!(machine.next((None, &state)), None); 71 | state.io.inputs.insert("x".into(), Value::Decimal(5.1)); 72 | assert_eq!( 73 | machine.next((None, &state)), 74 | Some(("step-one".into(), vec![])) 75 | ); 76 | assert_eq!(machine.next((Some("step-one"), &state)), None); 77 | state.io.inputs.insert("y".into(), Value::Decimal(7.1)); 78 | assert_eq!( 79 | machine.next((Some("step-one"), &state)), 80 | Some(("step-two".into(), vec![])) 81 | ); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /crates/msr-legacy/src/util.rs: -------------------------------------------------------------------------------- 1 | //! Little helpers 2 | 3 | /// Re-maps a number from one range to another. 4 | pub fn map_value(x: f64, x_min: f64, x_max: f64, y_min: f64, y_max: f64) -> f64 { 5 | let s = (y_max - y_min) / (x_max - x_min); 6 | (x - x_min) * s + y_min 7 | } 8 | 9 | /// Limit a value by minimum and maximum values. 10 | pub fn limit(min: Option, max: Option, mut value: f64) -> f64 { 11 | if let Some(max) = max { 12 | if value > max { 13 | value = max; 14 | } 15 | } 16 | if let Some(min) = min { 17 | if value < min { 18 | value = min; 19 | } 20 | } 21 | value 22 | } 23 | 24 | #[cfg(test)] 25 | #[allow(clippy::float_cmp)] 26 | mod tests { 27 | 28 | #[test] 29 | fn map_value() { 30 | assert_eq!(super::map_value(0.0, 4.0, 20.0, 0.0, 1.0), -0.25); 31 | assert_eq!(super::map_value(20.0, 4.0, 20.0, 0.16, 3.2), 3.2); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /crates/msr-legacy/src/value.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | #[cfg(feature = "serde")] 4 | use std::fmt; 5 | 6 | #[cfg(feature = "serde")] 7 | use serde::{ 8 | de::{Error, MapAccess, SeqAccess, Visitor}, 9 | Deserialize, Deserializer, Serialize, Serializer, 10 | }; 11 | 12 | /// A value representation within a MSR system. 13 | #[derive(Debug, Clone, PartialEq, PartialOrd)] 14 | pub enum Value { 15 | /// State of e.g. a digital input/output. 16 | Bit(bool), 17 | /// Value of e.g. an analog input/output. 18 | Decimal(f64), 19 | /// Value of e.g. a counter input. 20 | Integer(i64), 21 | /// Value of e.g. a serial communication device. 22 | Text(String), 23 | /// Binary data 24 | Bin(Vec), 25 | /// Timeout 26 | Timeout(Duration), 27 | } 28 | 29 | impl From for Value { 30 | fn from(b: bool) -> Value { 31 | Value::Bit(b) 32 | } 33 | } 34 | 35 | impl From for Value { 36 | fn from(v: f64) -> Value { 37 | Value::Decimal(v) 38 | } 39 | } 40 | 41 | impl From for Value { 42 | fn from(v: i64) -> Value { 43 | Value::Integer(v) 44 | } 45 | } 46 | 47 | impl From for Value { 48 | fn from(v: i32) -> Value { 49 | Value::Integer(v as i64) 50 | } 51 | } 52 | 53 | impl From for Value { 54 | fn from(v: u32) -> Value { 55 | Value::Integer(v as i64) 56 | } 57 | } 58 | 59 | impl From for Value { 60 | fn from(t: String) -> Value { 61 | Value::Text(t) 62 | } 63 | } 64 | 65 | impl From> for Value { 66 | fn from(b: Vec) -> Value { 67 | Value::Bin(b) 68 | } 69 | } 70 | 71 | impl From for Value { 72 | fn from(d: Duration) -> Value { 73 | Value::Timeout(d) 74 | } 75 | } 76 | 77 | #[cfg(feature = "serde")] 78 | impl Serialize for Value { 79 | fn serialize(&self, serializer: S) -> Result 80 | where 81 | S: Serializer, 82 | { 83 | match self { 84 | Value::Bit(b) => serializer.serialize_bool(*b), 85 | Value::Decimal(d) => serializer.serialize_f64(*d), 86 | Value::Integer(i) => serializer.serialize_i64(*i), 87 | Value::Text(t) => serializer.serialize_str(t), 88 | Value::Bin(b) => serializer.serialize_bytes(b), 89 | Value::Timeout(t) => t.serialize(serializer), 90 | } 91 | } 92 | } 93 | 94 | #[cfg(feature = "serde")] 95 | struct ValueVisitor; 96 | 97 | #[cfg(feature = "serde")] 98 | impl<'de> Visitor<'de> for ValueVisitor { 99 | type Value = Value; 100 | 101 | fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 102 | formatter.write_str("a boolean, a number, a string, an array of bytes or an timeout.") 103 | } 104 | fn visit_bool(self, value: bool) -> Result { 105 | Ok(Value::Bit(value)) 106 | } 107 | fn visit_f64(self, value: f64) -> Result { 108 | Ok(Value::Decimal(value)) 109 | } 110 | fn visit_i64(self, value: i64) -> Result { 111 | Ok(Value::Integer(value)) 112 | } 113 | fn visit_str(self, value: &str) -> Result 114 | where 115 | E: ::serde::de::Error, 116 | { 117 | Ok(Value::Text(value.into())) 118 | } 119 | fn visit_seq(self, mut access: A) -> Result 120 | where 121 | A: SeqAccess<'de>, 122 | { 123 | let mut bin: Vec = vec![]; 124 | 125 | while let Some(value) = access.next_element()? { 126 | bin.push(value); 127 | } 128 | Ok(Value::Bin(bin)) 129 | } 130 | fn visit_map(self, mut access: A) -> Result 131 | where 132 | A: MapAccess<'de>, 133 | A::Error: ::serde::de::Error, 134 | { 135 | let mut secs: Option = None; 136 | let mut nanos: Option = None; 137 | 138 | while let Some((key, value)) = access.next_entry()? { 139 | match key { 140 | "secs" => { 141 | secs = Some(value); 142 | } 143 | "nanos" => { 144 | nanos = Some(value as u32); 145 | } 146 | k => return Err(A::Error::custom(format!("Unknown key: {k}"))), 147 | } 148 | } 149 | if let Some(secs) = secs { 150 | if let Some(nanos) = nanos { 151 | return Ok(Value::Timeout(Duration::new(secs, nanos))); 152 | } 153 | } 154 | Err(A::Error::custom("Unknown map")) 155 | } 156 | } 157 | 158 | #[cfg(feature = "serde")] 159 | impl<'de> Deserialize<'de> for Value { 160 | fn deserialize(deserializer: D) -> Result 161 | where 162 | D: Deserializer<'de>, 163 | { 164 | deserializer.deserialize_any(ValueVisitor) 165 | } 166 | } 167 | 168 | #[cfg(test)] 169 | mod tests { 170 | use super::*; 171 | 172 | #[test] 173 | fn value_casting() { 174 | assert_eq!(Value::from(true), Value::Bit(true)); 175 | assert_eq!(Value::from(3.2_f64), Value::Decimal(3.2)); 176 | assert_eq!(Value::from(3_i64), Value::Integer(3)); 177 | assert_eq!(Value::from(3_i32), Value::Integer(3)); 178 | assert_eq!(Value::from(3_u32), Value::Integer(3)); 179 | assert_eq!( 180 | Value::from("txt".to_string()), 181 | Value::Text("txt".to_string()) 182 | ); 183 | assert_eq!(Value::from(vec![0x07]), Value::Bin(vec![0x07])); 184 | } 185 | 186 | #[cfg(feature = "serde")] 187 | #[test] 188 | fn value_serialization() { 189 | let b = Value::Bit(true); 190 | assert_eq!(serde_json::to_string(&b).unwrap(), "true"); 191 | 192 | let f = Value::Decimal(6.99); 193 | assert_eq!(serde_json::to_string(&f).unwrap(), "6.99"); 194 | 195 | let i = Value::Integer(-8); 196 | assert_eq!(serde_json::to_string(&i).unwrap(), "-8"); 197 | 198 | let t = Value::Text("blabla".into()); 199 | assert_eq!(serde_json::to_string(&t).unwrap(), "\"blabla\""); 200 | 201 | let b = Value::Bin(vec![0x45, 0xFF]); 202 | assert_eq!(serde_json::to_string(&b).unwrap(), "[69,255]"); 203 | 204 | let t = Value::Timeout(Duration::from_millis(1500)); 205 | assert_eq!( 206 | serde_json::to_string(&t).unwrap(), 207 | "{\"secs\":1,\"nanos\":500000000}" 208 | ); 209 | } 210 | 211 | #[cfg(feature = "serde")] 212 | #[test] 213 | fn value_deserialization() { 214 | let v: Value = serde_json::from_str("true").unwrap(); 215 | assert_eq!(v, Value::Bit(true)); 216 | 217 | let v: Value = serde_json::from_str("6.99").unwrap(); 218 | assert_eq!(v, Value::Decimal(6.99)); 219 | 220 | let v: Value = serde_json::from_str("-8").unwrap(); 221 | assert_eq!(v, Value::Integer(-8)); 222 | 223 | let v: Value = serde_json::from_str("\"blabla\"").unwrap(); 224 | assert_eq!(v, Value::Text("blabla".into())); 225 | 226 | let v: Value = serde_json::from_str("[69,255]").unwrap(); 227 | assert_eq!(v, Value::Bin(vec![0x45, 0xFF])); 228 | 229 | let v: Value = serde_json::from_str("{\"secs\":1,\"nanos\":500000000}").unwrap(); 230 | assert_eq!(v, Value::Timeout(Duration::from_millis(1500))); 231 | 232 | assert!(serde_json::from_str::("{\"secs\":1,\"nanooos\":500}").is_err()); 233 | assert!(serde_json::from_str::("{\"secs\":1}").is_err()); 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /crates/msr-plugin/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "msr-plugin" 3 | description = "Industrial Automation Toolbox - Plugin foundation" 4 | homepage.workspace = true 5 | repository.workspace = true 6 | license.workspace = true 7 | edition.workspace = true 8 | version.workspace = true 9 | rust-version.workspace = true 10 | 11 | [dependencies] 12 | log = "0.4.20" 13 | thiserror = "1.0.48" 14 | tokio = { version = "1.32.0", default-features = false, features = ["sync"] } 15 | 16 | # Workspace dependencies 17 | msr-core = "=0.3.6" 18 | -------------------------------------------------------------------------------- /crates/msr/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "msr" 3 | description = "Industrial Automation Toolbox" 4 | keywords = ["automation", "control", "plc", "msr", "fieldbus"] 5 | homepage.workspace = true 6 | repository.workspace = true 7 | license.workspace = true 8 | edition.workspace = true 9 | version.workspace = true 10 | rust-version.workspace = true 11 | 12 | [dependencies] 13 | # Workspace dependencies 14 | msr-core = "=0.3.6" 15 | msr-plugin = { version = "=0.3.6", optional = true } 16 | 17 | [features] 18 | default = [] 19 | plugin = ["msr-plugin"] 20 | 21 | [dev-dependencies] 22 | anyhow = "1.0.75" 23 | env_logger = "0.10.0" 24 | log = "0.4.20" 25 | tokio = { version = "1.32.0", features = ["full"] } 26 | 27 | # Workspace dev-dependencies 28 | msr-plugin = "=0.3.6" 29 | -------------------------------------------------------------------------------- /crates/msr/src/lib.rs: -------------------------------------------------------------------------------- 1 | // FIXME: Enable `deny(missing_docs)` before release 2 | //#![deny(missing_docs)] 3 | 4 | #![warn(rust_2018_idioms)] 5 | #![warn(rust_2021_compatibility)] 6 | #![warn(missing_debug_implementations)] 7 | #![warn(unreachable_pub)] 8 | #![warn(unsafe_code)] 9 | #![warn(rustdoc::broken_intra_doc_links)] 10 | #![warn(clippy::pedantic)] 11 | // Additional restrictions 12 | #![warn(clippy::clone_on_ref_ptr)] 13 | #![warn(clippy::self_named_module_files)] 14 | 15 | pub use msr_core as core; 16 | 17 | #[cfg(feature = "plugin")] 18 | pub use msr_plugin as plugin; 19 | -------------------------------------------------------------------------------- /doc/.gitignore: -------------------------------------------------------------------------------- 1 | book 2 | -------------------------------------------------------------------------------- /doc/README.md: -------------------------------------------------------------------------------- 1 | # slowtec MSR - System Documentation 2 | 3 | ## Installation 4 | 5 | Make sure 6 | 7 | - [Rust](https://www.rust-lang.org/), 8 | - [PlantUML](https://plantuml.com/) and 9 | - [Graphviz](https://graphviz.org/) 10 | 11 | are installed on your system. 12 | 13 | Then you can install [mdBook](https://github.com/rust-lang/mdBook): 14 | 15 | ```sh 16 | cargo install mdbook 17 | cargo install mdbook-linkcheck 18 | cargo install mdbook-plantuml 19 | ``` 20 | 21 | ## Usage 22 | 23 | The environment variable `RELATIVE_INCLUDE` has to be set to avoid fetching contents 24 | from [C4-PlantUML on GitHub](https://github.com/plantuml-stdlib/C4-PlantUML)! 25 | 26 | ### Build 27 | 28 | ```sh 29 | RELATIVE_INCLUDE=. mdbook build 30 | ``` 31 | 32 | ### Serve interactively 33 | 34 | ```sh 35 | RELATIVE_INCLUDE=. mdbook serve 36 | ``` 37 | 38 | For further information please look at the 39 | [mdBook Documentation](https://rust-lang.github.io/mdBook/index.html). 40 | -------------------------------------------------------------------------------- /doc/book.toml: -------------------------------------------------------------------------------- 1 | [book] 2 | title = "MSR - Industrial Automation Toolbox" 3 | authors = ["slowtec GmbH"] 4 | language = "en" 5 | multilingual = false 6 | src = "src" 7 | 8 | [preprocessor.plantuml] 9 | plantuml-cmd = "plantuml" 10 | 11 | [output.html] 12 | git-repository-url = "https://github.com/slowtec/msr" 13 | edit-url-template = "https://github.com/slowtec/msr/edit/main/doc/{path}" 14 | 15 | [output.linkcheck] 16 | -------------------------------------------------------------------------------- /doc/c4-plantuml/C4_Component.puml: -------------------------------------------------------------------------------- 1 | ' convert it with additional command line argument -DRELATIVE_INCLUDE="." to use locally 2 | !if %variable_exists("RELATIVE_INCLUDE") 3 | !include %get_variable_value("RELATIVE_INCLUDE")/C4_Container.puml 4 | !else 5 | !include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml 6 | !endif 7 | 8 | ' Scope: A single container. 9 | ' Primary elements: Components within the container in scope. 10 | ' Supporting elements: Containers (within the software system in scope) plus people and software systems directly connected to the components. 11 | ' Intended audience: Software architects and developers. 12 | 13 | ' Colors 14 | ' ################################## 15 | 16 | !global $COMPONENT_FONT_COLOR = "#000000" 17 | 18 | !global $COMPONENT_BG_COLOR = "#85BBF0" 19 | !global $COMPONENT_BORDER_COLOR = "#78A8D8" 20 | !global $EXTERNAL_COMPONENT_BG_COLOR = "#CCCCCC" 21 | !global $EXTERNAL_COMPONENT_BORDER_COLOR = "#BFBFBF" 22 | 23 | ' Styling 24 | ' ################################## 25 | 26 | UpdateElementStyle("component", $COMPONENT_BG_COLOR, $COMPONENT_FONT_COLOR, $COMPONENT_BORDER_COLOR) 27 | UpdateElementStyle("external_component", $EXTERNAL_COMPONENT_BG_COLOR, $COMPONENT_FONT_COLOR, $EXTERNAL_COMPONENT_BORDER_COLOR) 28 | 29 | ' shortcuts with default colors 30 | !unquoted procedure AddComponentTag($tagStereo, $bgColor=$COMPONENT_BG_COLOR, $fontColor=$ELEMENT_FONT_COLOR, $borderColor=$COMPONENT_BORDER_COLOR, $shadowing="", $shape="", $sprite="", $techn="", $legendText="", $legendSprite="") 31 | AddElementTag($tagStereo, $bgColor, $fontColor, $borderColor, $shadowing, $shape, $sprite, $techn, $legendText, $legendSprite) 32 | !endprocedure 33 | !unquoted procedure AddExternalComponentTag($tagStereo, $bgColor=$EXTERNAL_COMPONENT_BG_COLOR, $fontColor=$ELEMENT_FONT_COLOR, $borderColor=$EXTERNAL_COMPONENT_BORDER_COLOR, $shadowing="", $shape="", $sprite="", $techn="", $legendText="", $legendSprite="") 34 | AddElementTag($tagStereo, $bgColor, $fontColor, $borderColor, $shadowing, $shape, $sprite, $techn, $legendText, $legendSprite) 35 | !endprocedure 36 | 37 | ' Layout 38 | ' ################################## 39 | 40 | SetDefaultLegendEntries("person\nsystem\ncontainer\ncomponent\nexternal_person\nexternal_system\nexternal_container\nexternal_component") 41 | 42 | !procedure LAYOUT_WITH_LEGEND() 43 | hide stereotype 44 | legend right 45 | |**Legend** | 46 | |<$PERSON_BG_COLOR> person | 47 | |<$SYSTEM_BG_COLOR> system | 48 | |<$CONTAINER_BG_COLOR> container | 49 | |<$COMPONENT_BG_COLOR> component | 50 | |<$EXTERNAL_PERSON_BG_COLOR> external person | 51 | |<$EXTERNAL_SYSTEM_BG_COLOR> external system | 52 | |<$EXTERNAL_CONTAINER_BG_COLOR> external container | 53 | |<$EXTERNAL_COMPONENT_BG_COLOR> external component | 54 | endlegend 55 | !endprocedure 56 | 57 | ' Elements 58 | ' ################################## 59 | 60 | !function $getComponent($label, $techn, $descr, $sprite) 61 | !if ($descr == "") && ($sprite == "") 62 | !return '=='+$label+'\n//['+$techn+']//' 63 | !endif 64 | !if ($descr == "") && ($sprite != "") 65 | !return $getSprite($sprite)+'\n=='+$label+'\n//['+$techn+']//' 66 | !endif 67 | !if ($descr != "") && ($sprite == "") 68 | !return '=='+$label+'\n//['+$techn+']//\n\n '+$descr 69 | !endif 70 | !if ($descr != "") && ($sprite != "") 71 | !return $getSprite($sprite)+'\n=='+$label+'\n//['+$techn+']//\n\n '+$descr 72 | !endif 73 | !endfunction 74 | 75 | !unquoted procedure Component($alias, $label, $techn="", $descr="", $sprite="", $tags="", $link="") 76 | !$sprite=$toElementArg($sprite, $tags, "ElementTagSprite", "component") 77 | !$techn=$toElementArg($techn, $tags, "ElementTagTechn", "component") 78 | rectangle "$getComponent($label, $techn, $descr, $sprite)$getProps()" $toStereos("component",$tags) as $alias $getLink($link) 79 | !endprocedure 80 | 81 | !unquoted procedure ComponentDb($alias, $label, $techn="", $descr="", $sprite="", $tags="", $link="") 82 | !$sprite=$toElementArg($sprite, $tags, "ElementTagSprite", "component") 83 | !$techn=$toElementArg($techn, $tags, "ElementTagTechn", "component") 84 | database "$getComponent($label, $techn, $descr, $sprite)$getProps()" $toStereos("component",$tags) as $alias $getLink($link) 85 | !endprocedure 86 | 87 | !unquoted procedure ComponentQueue($alias, $label, $techn="", $descr="", $sprite="", $tags="", $link="") 88 | !$sprite=$toElementArg($sprite, $tags, "ElementTagSprite", "component") 89 | !$techn=$toElementArg($techn, $tags, "ElementTagTechn", "component") 90 | queue "$getComponent($label, $techn, $descr, $sprite)$getProps()" $toStereos("component",$tags) as $alias $getLink($link) 91 | !endprocedure 92 | 93 | !unquoted procedure Component_Ext($alias, $label, $techn="", $descr="", $sprite="", $tags="", $link="") 94 | !$sprite=$toElementArg($sprite, $tags, "ElementTagSprite", "external_component") 95 | !$techn=$toElementArg($techn, $tags, "ElementTagTechn", "external_component") 96 | rectangle "$getComponent($label, $techn, $descr, $sprite)$getProps()" $toStereos("external_component",$tags) as $alias $getLink($link) 97 | !endprocedure 98 | 99 | !unquoted procedure ComponentDb_Ext($alias, $label, $techn="", $descr="", $sprite="", $tags="", $link="") 100 | !$sprite=$toElementArg($sprite, $tags, "ElementTagSprite", "external_component") 101 | !$techn=$toElementArg($techn, $tags, "ElementTagTechn", "external_component") 102 | database "$getComponent($label, $techn, $descr, $sprite)$getProps()" $toStereos("external_component",$tags) as $alias $getLink($link) 103 | !endprocedure 104 | 105 | !unquoted procedure ComponentQueue_Ext($alias, $label, $techn="", $descr="", $sprite="", $tags="", $link="") 106 | !$sprite=$toElementArg($sprite, $tags, "ElementTagSprite", "external_component") 107 | !$techn=$toElementArg($techn, $tags, "ElementTagTechn", "external_component") 108 | queue "$getComponent($label, $techn, $descr, $sprite)$getProps()" $toStereos("external_component",$tags) as $alias $getLink($link) 109 | !endprocedure 110 | -------------------------------------------------------------------------------- /doc/c4-plantuml/C4_Container.puml: -------------------------------------------------------------------------------- 1 | ' convert it with additional command line argument -DRELATIVE_INCLUDE="." to use locally 2 | !if %variable_exists("RELATIVE_INCLUDE") 3 | !include %get_variable_value("RELATIVE_INCLUDE")/C4_Context.puml 4 | !else 5 | !include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Context.puml 6 | !endif 7 | 8 | ' Scope: A single software system. 9 | ' Primary elements: Containers within the software system in scope. 10 | ' Supporting elements: People and software systems directly connected to the containers. 11 | ' Intended audience: Technical people inside and outside of the software development team; including software architects, developers and operations/support staff. 12 | 13 | ' Colors 14 | ' ################################## 15 | 16 | !global $CONTAINER_BG_COLOR = "#438DD5" 17 | !global $CONTAINER_BORDER_COLOR = "#3C7FC0" 18 | !global $EXTERNAL_CONTAINER_BG_COLOR = "#B3B3B3" 19 | !global $EXTERNAL_CONTAINER_BORDER_COLOR = "#A6A6A6" 20 | 21 | ' Styling 22 | ' ################################## 23 | UpdateElementStyle("container", $CONTAINER_BG_COLOR, $ELEMENT_FONT_COLOR, $CONTAINER_BORDER_COLOR) 24 | UpdateElementStyle("external_container", $EXTERNAL_CONTAINER_BG_COLOR, $ELEMENT_FONT_COLOR, $EXTERNAL_CONTAINER_BORDER_COLOR) 25 | 26 | ' shortcuts with default colors 27 | !unquoted procedure AddContainerTag($tagStereo, $bgColor=$CONTAINER_BG_COLOR, $fontColor=$ELEMENT_FONT_COLOR, $borderColor=$CONTAINER_BORDER_COLOR, $shadowing="", $shape="", $sprite="", $techn="", $legendText="", $legendSprite="") 28 | AddElementTag($tagStereo, $bgColor, $fontColor, $borderColor, $shadowing, $shape, $sprite, $techn, $legendText, $legendSprite) 29 | !endprocedure 30 | !unquoted procedure AddExternalContainerTag($tagStereo, $bgColor=$EXTERNAL_CONTAINER_BG_COLOR, $fontColor=$ELEMENT_FONT_COLOR, $borderColor=$EXTERNAL_CONTAINER_BORDER_COLOR, $shadowing="", $shape="", $sprite="", $techn="", $legendText="", $legendSprite="") 31 | AddElementTag($tagStereo, $bgColor, $fontColor, $borderColor, $shadowing, $shape, $sprite, $techn, $legendText, $legendSprite) 32 | !endprocedure 33 | 34 | ' Layout 35 | ' ################################## 36 | 37 | SetDefaultLegendEntries("person\nsystem\ncontainer\nexternal_person\nexternal_system\nexternal_container") 38 | 39 | !procedure LAYOUT_WITH_LEGEND() 40 | hide stereotype 41 | legend right 42 | |**Legend** | 43 | |<$PERSON_BG_COLOR> person | 44 | |<$SYSTEM_BG_COLOR> system | 45 | |<$CONTAINER_BG_COLOR> container | 46 | |<$EXTERNAL_PERSON_BG_COLOR> external person | 47 | |<$EXTERNAL_SYSTEM_BG_COLOR> external system | 48 | |<$EXTERNAL_CONTAINER_BG_COLOR> external container | 49 | endlegend 50 | !endprocedure 51 | 52 | ' Elements 53 | ' ################################## 54 | 55 | !function $getContainer($label, $techn, $descr, $sprite) 56 | !if ($descr == "") && ($sprite == "") 57 | !return '=='+$label+'\n//['+$techn+']//' 58 | !endif 59 | !if ($descr == "") && ($sprite != "") 60 | !return $getSprite($sprite)+'\n=='+$label+'\n//['+$techn+']//' 61 | !endif 62 | !if ($descr != "") && ($sprite == "") 63 | !return '=='+$label+'\n//['+$techn+']//\n\n '+$descr 64 | !endif 65 | !if ($descr != "") && ($sprite != "") 66 | !return $getSprite($sprite)+'\n=='+$label+'\n//['+$techn+']//\n\n '+$descr 67 | !endif 68 | !endfunction 69 | 70 | !unquoted procedure Container($alias, $label, $techn="", $descr="", $sprite="", $tags="", $link="") 71 | !$sprite=$toElementArg($sprite, $tags, "ElementTagSprite", "container") 72 | !$techn=$toElementArg($techn, $tags, "ElementTagTechn", "container") 73 | rectangle "$getContainer($label, $techn, $descr, $sprite)$getProps()" $toStereos("container", $tags) as $alias $getLink($link) 74 | !endprocedure 75 | 76 | !unquoted procedure ContainerDb($alias, $label, $techn="", $descr="", $sprite="", $tags="", $link="") 77 | !$sprite=$toElementArg($sprite, $tags, "ElementTagSprite", "container") 78 | !$techn=$toElementArg($techn, $tags, "ElementTagTechn", "container") 79 | database "$getContainer($label, $techn, $descr, $sprite)$getProps()" $toStereos("container", $tags) as $alias $getLink($link) 80 | !endprocedure 81 | 82 | !unquoted procedure ContainerQueue($alias, $label, $techn="", $descr="", $sprite="", $tags="", $link="") 83 | !$sprite=$toElementArg($sprite, $tags, "ElementTagSprite", "container") 84 | !$techn=$toElementArg($techn, $tags, "ElementTagTechn", "container") 85 | queue "$getContainer($label, $techn, $descr, $sprite)$getProps()" $toStereos("container", $tags) as $alias $getLink($link) 86 | !endprocedure 87 | 88 | !unquoted procedure Container_Ext($alias, $label, $techn="", $descr="", $sprite="", $tags="", $link="") 89 | !$sprite=$toElementArg($sprite, $tags, "ElementTagSprite", "external_container") 90 | !$techn=$toElementArg($techn, $tags, "ElementTagTechn", "external_container") 91 | rectangle "$getContainer($label, $techn, $descr, $sprite)$getProps()" $toStereos("external_container", $tags) as $alias $getLink($link) 92 | !endprocedure 93 | 94 | !unquoted procedure ContainerDb_Ext($alias, $label, $techn="", $descr="", $sprite="", $tags="", $link="") 95 | !$sprite=$toElementArg($sprite, $tags, "ElementTagSprite", "external_container") 96 | !$techn=$toElementArg($techn, $tags, "ElementTagTechn", "external_container") 97 | database "$getContainer($label, $techn, $descr, $sprite)$getProps()" $toStereos("external_container", $tags) as $alias $getLink($link) 98 | !endprocedure 99 | 100 | !unquoted procedure ContainerQueue_Ext($alias, $label, $techn="", $descr="", $sprite="", $tags="", $link="") 101 | !$sprite=$toElementArg($sprite, $tags, "ElementTagSprite", "external_container") 102 | !$techn=$toElementArg($techn, $tags, "ElementTagTechn", "external_container") 103 | queue "$getContainer($label, $techn, $descr, $sprite)$getProps()" $toStereos("external_container", $tags) as $alias $getLink($link) 104 | !endprocedure 105 | 106 | ' Boundaries 107 | ' ################################## 108 | 109 | !unquoted procedure Container_Boundary($alias, $label, $tags="", $link="") 110 | Boundary($alias, $label, "Container", $tags, $link) 111 | !endprocedure 112 | -------------------------------------------------------------------------------- /doc/c4-plantuml/C4_Deployment.puml: -------------------------------------------------------------------------------- 1 | ' convert it with additional command line argument -DRELATIVE_INCLUDE="." to use locally 2 | !if %variable_exists("RELATIVE_INCLUDE") 3 | !include %get_variable_value("RELATIVE_INCLUDE")/C4_Container.puml 4 | !else 5 | !include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml 6 | !endif 7 | 8 | ' Colors 9 | ' ################################## 10 | 11 | !global $NODE_FONT_COLOR = "#000000" 12 | !global $NODE_BG_COLOR = "#FFFFFF" 13 | !global $NODE_BORDER_COLOR = "#A2A2A2" 14 | 15 | ' Styling 16 | ' ################################## 17 | 18 | UpdateElementStyle("node", $bgColor=$NODE_BG_COLOR, $fontColor=$NODE_FONT_COLOR, $borderColor=$NODE_BORDER_COLOR) 19 | skinparam rectangle<> { 20 | FontStyle normal 21 | } 22 | 23 | ' shortcuts with default colors 24 | ' node specific: $type reuses $techn definition of $tags 25 | !unquoted procedure AddNodeTag($tagStereo, $bgColor=$NODE_BG_COLOR, $fontColor=$NODE_FONT_COLOR, $borderColor=$NODE_BORDER_COLOR, $shadowing="", $shape="", $sprite="", $techn="", $legendText="", $legendSprite="") 26 | AddElementTag($tagStereo, $bgColor, $fontColor, $borderColor, $shadowing, $shape, $sprite, $techn, $legendText, $legendSprite) 27 | !endprocedure 28 | 29 | ' Layout 30 | ' ################################## 31 | 32 | ' comment if node should not be added to legend. No calculated legend extension required 33 | SetDefaultLegendEntries("person\nsystem\ncontainer\nexternal_person\nexternal_system\nexternal_container\nnode") 34 | 35 | ' Line breaks 36 | ' ################################## 37 | 38 | ' PlantUML supports no automatic line breaks of "PlantUML containers" (C4 Deployment_Node is a "PlantUML container") 39 | ' therefore (Deployment_)Node() implements an automatic line break based on spaces (like in all other objects). 40 | ' If a $type contains \n then these are used (and no automatic space based line breaks are done) 41 | ' $NODE_TYPE_MAX_CHAR_WIDTH defines the automatic line break position 42 | !global $NODE_TYPE_MAX_CHAR_WIDTH = 35 43 | !global $NODE_DESCR_MAX_CHAR_WIDTH = 32 44 | 45 | !unquoted function $breakNode($type, $widthStr) 46 | !$width = %intval($widthStr) 47 | !$multiLine = "" 48 | !if (%strpos($type, "\n") >= 0) 49 | !while (%strpos($type, "\n") >= 0) 50 | !$brPos = %strpos($type, "\n") 51 | !$multiLine = $multiLine + %substr($type, 0, $brPos) + '\n' 52 | !$type = %substr($type, $brPos+2) 53 | !endwhile 54 | !else 55 | !while (%strlen($type) > $width) 56 | !$brPos = $width 57 | !while ($brPos > 0 && %substr($type, $brPos, 1) != ' ') 58 | !$brPos = $brPos - 1 59 | !endwhile 60 | 61 | !if ($brPos < 1) 62 | !$brPos = %strpos($type, " ") 63 | !else 64 | !endif 65 | 66 | !if ($brPos > 0) 67 | !$multiLine = $multiLine + %substr($type, 0, $brPos) + '\n' 68 | !$type = %substr($type, $brPos + 1) 69 | !else 70 | !$multiLine = $multiLine+ $type 71 | !$type = "" 72 | !endif 73 | !endwhile 74 | !endif 75 | !if (%strlen($type) > 0) 76 | !$multiLine = $multiLine + $type 77 | !endif 78 | !return $multiLine 79 | !endfunction 80 | 81 | ' Elements 82 | ' ################################## 83 | 84 | !function $getNode($label, $type, $descr, $sprite) 85 | !$nodeText = "" 86 | !if ($sprite != "") 87 | !$nodeText = $nodeText + $getSprite($sprite) + '\n' 88 | !endif 89 | !$nodeText = $nodeText + '==' + $label 90 | !if ($type != "") 91 | !$nodeText = $nodeText + '\n[' + $breakNode($type, $NODE_TYPE_MAX_CHAR_WIDTH) + ']' 92 | !endif 93 | !if ($descr != "") 94 | !$nodeText = $nodeText + '\n\n' + $breakDescr($descr, $NODE_DESCR_MAX_CHAR_WIDTH) 95 | !endif 96 | !return $nodeText 97 | !endfunction 98 | 99 | !function $getNode_L($label, $type, $descr, $sprite) 100 | !$nodeText = "" 101 | !if ($sprite != "") 102 | !$nodeText = $nodeText + $getSprite($sprite) + '\l' 103 | !endif 104 | !$nodeText = $nodeText + '==' + $label 105 | !if ($type != "") 106 | !$nodeText = $nodeText + '\l[' + $breakNode($type, $NODE_TYPE_MAX_CHAR_WIDTH) + ']' 107 | !endif 108 | !if ($descr != "") 109 | !$nodeText = $nodeText + '\l\l' + $breakDescr($descr, $NODE_DESCR_MAX_CHAR_WIDTH) 110 | !endif 111 | !return $nodeText 112 | !endfunction 113 | 114 | !function $getNode_R($label, $type, $descr, $sprite) 115 | !$nodeText = "" 116 | !if ($sprite != "") 117 | !$nodeText = $nodeText + $getSprite($sprite) + '\r' 118 | !endif 119 | !$nodeText = $nodeText + '==' + $label 120 | !if ($type != "") 121 | !$nodeText = $nodeText + '\r[' + $breakNode($type, $NODE_TYPE_MAX_CHAR_WIDTH) + ']' 122 | !endif 123 | !if ($descr != "") 124 | !$nodeText = $nodeText + '\r\r' + $breakDescr($descr, $NODE_DESCR_MAX_CHAR_WIDTH) 125 | !endif 126 | !return $nodeText 127 | !endfunction 128 | 129 | !unquoted procedure Deployment_Node($alias, $label, $type="", $descr="", $sprite="", $tags="", $link="") 130 | !$sprite=$toElementArg($sprite, $tags, "ElementTagSprite", "node") 131 | ' nodes $type reuses $techn definition of $tags 132 | !$type=$toElementArg($type, $tags, "ElementTagTechn", "node") 133 | rectangle "$getNode($label, $type, $descr, $sprite)$getProps()" $toStereos("node",$tags) as $alias $getLink($link) 134 | !endprocedure 135 | 136 | !unquoted procedure Deployment_Node_L($alias, $label, $type="", $descr="", $sprite="", $tags="", $link="") 137 | !$sprite=$toElementArg($sprite, $tags, "ElementTagSprite", "node") 138 | ' nodes $type reuses $techn definition of $tags 139 | !$type=$toElementArg($type, $tags, "ElementTagTechn", "node") 140 | rectangle "$getNode_L($label, $type, $descr, $sprite)$getProps_L()" $toStereos("node",$tags) as $alias $getLink($link) 141 | !endprocedure 142 | 143 | !unquoted procedure Deployment_Node_R($alias, $label, $type="", $descr="", $sprite="", $tags="", $link="") 144 | !$sprite=$toElementArg($sprite, $tags, "ElementTagSprite", "node") 145 | ' nodes $type reuses $techn definition of $tags 146 | !$type=$toElementArg($type, $tags, "ElementTagTechn", "node") 147 | rectangle "$getNode_R($label, $type, $descr, $sprite)$getProps_R()" $toStereos("node",$tags) as $alias $getLink($link) 148 | !endprocedure 149 | 150 | !unquoted procedure Node($alias, $label, $type="", $descr="", $sprite="", $tags="", $link="") 151 | !$sprite=$toElementArg($sprite, $tags, "ElementTagSprite", "node") 152 | ' nodes $type reuses $techn definition of $tags 153 | !$type=$toElementArg($type, $tags, "ElementTagTechn", "node") 154 | rectangle "$getNode($label, $type, $descr, $sprite)$getProps()" $toStereos("node",$tags) as $alias $getLink($link) 155 | !endprocedure 156 | 157 | !unquoted procedure Node_L($alias, $label, $type="", $descr="", $sprite="", $tags="", $link="") 158 | !$sprite=$toElementArg($sprite, $tags, "ElementTagSprite", "node") 159 | ' nodes $type reuses $techn definition of $tags 160 | !$type=$toElementArg($type, $tags, "ElementTagTechn", "node") 161 | rectangle "$getNode_L($label, $type, $descr, $sprite)$getProps_L()" $toStereos("node",$tags) as $alias $getLink($link) 162 | !endprocedure 163 | 164 | !unquoted procedure Node_R($alias, $label, $type="", $descr="", $sprite="", $tags="", $link="") 165 | !$sprite=$toElementArg($sprite, $tags, "ElementTagSprite", "node") 166 | ' nodes $type reuses $techn definition of $tags 167 | !$type=$toElementArg($type, $tags, "ElementTagTechn", "node") 168 | rectangle "$getNode_R($label, $type, $descr, $sprite)$getProps_R()" $toStereos("node",$tags) as $alias $getLink($link) 169 | !endprocedure 170 | -------------------------------------------------------------------------------- /doc/c4-plantuml/C4_Dynamic.puml: -------------------------------------------------------------------------------- 1 | ' convert it with additional command line argument -DRELATIVE_INCLUDE="." to use locally 2 | !if %variable_exists("RELATIVE_INCLUDE") 3 | !include %get_variable_value("RELATIVE_INCLUDE")/C4_Component.puml 4 | !else 5 | !include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Component.puml 6 | !endif 7 | 8 | ' Scope: Interactions in an enterprise, software system or container. 9 | ' Primary and supporting elements: Depends on the diagram scope - 10 | ' enterprise - people and software systems Related to the enterprise in scope 11 | ' software system - see system context or container diagrams, 12 | ' container - see component diagram. 13 | ' Intended audience: Technical and non-technical people, inside and outside of the software development team. 14 | 15 | ' Dynamic diagram introduces (automatically) numbered interactions: 16 | ' (lowercase) increment($offset=1): increase current index (procedure which has no direct output) 17 | ' (lowercase) setIndex($new_index): set the new index (procedure which has no direct output) 18 | ' 19 | ' (Uppercase) LastIndex(): return the last used index (function which can be used as argument) 20 | ' (Uppercase) Index($offset=1): returns current index and calculates next index (function which can be used as argument) 21 | ' (Uppercase) SetIndex($new_index): returns new set index and calculates next index (function which can be used as argument) 22 | 23 | ' Index 24 | ' ################################## 25 | 26 | !$lastIndex = 0 27 | !$index = 1 28 | 29 | !procedure increment($offset=1) 30 | !$lastIndex = $index 31 | !$index = $index + $offset 32 | !endprocedure 33 | 34 | !procedure setIndex($new_index) 35 | !$lastIndex = $index 36 | !$index = $new_index 37 | !endprocedure 38 | 39 | !function Index($offset=1) 40 | !$lastIndex = $index 41 | !$index = $lastIndex + $offset 42 | !return $lastIndex 43 | !endfunction 44 | 45 | !function LastIndex() 46 | !return $lastIndex 47 | !endfunction 48 | 49 | !function SetIndex($new_index, $offset=1) 50 | !$lastIndex = $new_index 51 | !$index = $new_index + $offset 52 | !return $lastIndex 53 | !endfunction 54 | 55 | ' Relationship override 56 | ' ################################## 57 | 58 | ' Relationship 59 | ' ################################## 60 | 61 | !unquoted procedure Rel_($e_index, $alias1, $alias2, $label, $direction) 62 | $alias1 $direction $alias2 : **$e_index: $label** 63 | !endprocedure 64 | !unquoted procedure Rel_($e_index, $alias1, $alias2, $label, $techn, $direction) 65 | $alias1 $direction $alias2 : **$e_index: $label**\n//[$techn]// 66 | !endprocedure 67 | 68 | !unquoted procedure Rel($from, $to, $label, $techn="", $descr="", $sprite="", $tags="", $link="") 69 | $getRel("-->>", $from, $to, Index() + ": " + $label, $techn, $descr, $sprite, $tags, $link) 70 | !endprocedure 71 | !unquoted procedure RelIndex($e_index, $from, $to, $label, $techn="", $descr="", $sprite="", $tags="", $link="") 72 | $getRel("-->>", $from, $to, $e_index + ": " + $label, $techn, $descr, $sprite, $tags, $link) 73 | !endprocedure 74 | 75 | !unquoted procedure Rel_Back($from, $to, $label, $techn="", $descr="", $sprite="", $tags="", $link="") 76 | $getRel("<<--", $from, $to, Index() + ": " + $label, $techn, $descr, $sprite, $tags, $link) 77 | !endprocedure 78 | !unquoted procedure RelIndex_Back($e_index, $from, $to, $label, $techn="", $descr="", $sprite="", $tags="", $link="") 79 | $getRel("<<--", $from, $to, $e_index + ": " + $label, $techn, $descr, $sprite, $tags, $link) 80 | !endprocedure 81 | 82 | !unquoted procedure Rel_Neighbor($from, $to, $label, $techn="", $descr="", $sprite="", $tags="", $link="") 83 | $getRel("->>", $from, $to, Index() + ": " + $label, $techn, $descr, $sprite, $tags, $link) 84 | !endprocedure 85 | !unquoted procedure RelIndex_Neighbor($e_index, $from, $to, $label, $techn="", $descr="", $sprite="", $tags="", $link="") 86 | $getRel("->>", $from, $to, $e_index + ": " + $label, $techn, $descr, $sprite, $tags, $link) 87 | !endprocedure 88 | 89 | !unquoted procedure Rel_Back_Neighbor($from, $to, $label, $techn="", $descr="", $sprite="", $tags="", $link="") 90 | $getRel("<<-", $from, $to, Index() + ": " + $label, $techn, $descr, $sprite, $tags, $link) 91 | !endprocedure 92 | !unquoted procedure RelIndex_Back_Neighbor($e_index, $from, $to, $label, $techn="", $descr="", $sprite="", $tags="", $link="") 93 | $getRel("<<-", $from, $to, $e_index + ": " + $label, $techn, $descr, $sprite, $tags, $link) 94 | !endprocedure 95 | 96 | !unquoted procedure Rel_D($from, $to, $label, $techn="", $descr="", $sprite="", $tags="", $link="") 97 | $getRel($down("-","->>"), $from, $to, Index() + ": " + $label, $techn, $descr, $sprite, $tags, $link) 98 | !endprocedure 99 | !unquoted procedure Rel_Down($from, $to, $label, $techn="", $descr="", $sprite="", $tags="", $link="") 100 | $getRel($down("-","->>"), $from, $to, Index() + ": " + $label, $techn, $descr, $sprite, $tags, $link) 101 | !endprocedure 102 | !unquoted procedure RelIndex_D($e_index, $from, $to, $label, $techn="", $descr="", $sprite="", $tags="", $link="") 103 | $getRel($down("-","->>"), $from, $to, $e_index + ": " + $label, $techn, $descr, $sprite, $tags, $link) 104 | !endprocedure 105 | !unquoted procedure RelIndex_Down($e_index, $from, $to, $label, $techn="", $descr="", $sprite="", $tags="", $link="") 106 | $getRel($down("-","->>"), $from, $to, $e_index + ": " + $label, $techn, $descr, $sprite, $tags, $link) 107 | !endprocedure 108 | 109 | !unquoted procedure Rel_U($from, $to, $label, $techn="", $descr="", $sprite="", $tags="", $link="") 110 | $getRel($up("-","->>"), $from, $to, Index() + ": " + $label, $techn, $descr, $sprite, $tags, $link) 111 | !endprocedure 112 | !unquoted procedure Rel_Up($from, $to, $label, $techn="", $descr="", $sprite="", $tags="", $link="") 113 | $getRel($up("-","->>"), $from, $to, Index() + ": " + $label, $techn, $descr, $sprite, $tags, $link) 114 | !endprocedure 115 | !unquoted procedure RelIndex_U($e_index, $from, $to, $label, $techn="", $descr="", $sprite="", $tags="", $link="") 116 | $getRel($up("-","->>"), $from, $to, $e_index + ": " + $label, $techn, $descr, $sprite, $tags, $link) 117 | !endprocedure 118 | !unquoted procedure RelIndex_Up($e_index, $from, $to, $label, $techn="", $descr="", $sprite="", $tags="", $link="") 119 | $getRel($up("-","->>"), $from, $to, $e_index + ": " + $label, $techn, $descr, $sprite, $tags, $link) 120 | !endprocedure 121 | 122 | !unquoted procedure Rel_L($from, $to, $label, $techn="", $descr="", $sprite="", $tags="", $link="") 123 | $getRel($left("-","->>"), $from, $to, Index() + ": " + $label, $techn, $descr, $sprite, $tags, $link) 124 | !endprocedure 125 | !unquoted procedure Rel_Left($from, $to, $label, $techn="", $descr="", $sprite="", $tags="", $link="") 126 | $getRel($left("-","->>"), $from, $to, Index() + ": " + $label, $techn, $descr, $sprite, $tags, $link) 127 | !endprocedure 128 | !unquoted procedure RelIndex_L($e_index, $from, $to, $label, $techn="", $descr="", $sprite="", $tags="", $link="") 129 | $getRel($left("-","->>"), $from, $to, $e_index + ": " + $label, $techn, $descr, $sprite, $tags, $link) 130 | !endprocedure 131 | !unquoted procedure RelIndex_Left($e_index, $from, $to, $label, $techn="", $descr="", $sprite="", $tags="", $link="") 132 | $getRel($left("-","->>"), $from, $to, $e_index + ": " + $label, $techn, $descr, $sprite, $tags, $link) 133 | !endprocedure 134 | 135 | !unquoted procedure Rel_R($from, $to, $label, $techn="", $descr="", $sprite="", $tags="", $link="") 136 | $getRel($right("-","->>"), $from, $to, Index() + ": " + $label, $techn, $descr, $sprite, $tags, $link) 137 | !endprocedure 138 | !unquoted procedure Rel_Right($from, $to, $label, $techn="", $descr="", $sprite="", $tags="", $link="") 139 | $getRel($right("-","->>"), $from, $to, Index() + ": " + $label, $techn, $descr, $sprite, $tags, $link) 140 | !endprocedure 141 | !unquoted procedure RelIndex_R($e_index, $from, $to, $label, $techn="", $descr="", $sprite="", $tags="", $link="") 142 | $getRel($right("-","->>"), $from, $to, $e_index + ": " + $label, $techn, $descr, $sprite, $tags, $link) 143 | !endprocedure 144 | !unquoted procedure RelIndex_Right($e_index, $from, $to, $label, $techn="", $descr="", $sprite="", $tags="", $link="") 145 | $getRel($right("-","->>"), $from, $to, $e_index + ": " + $label, $techn, $descr, $sprite, $tags, $link) 146 | !endprocedure 147 | -------------------------------------------------------------------------------- /doc/src/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | - [Introduction](introduction.md) 4 | - [Architecture](architecture.md) 5 | - [Context](architecture/context.md) 6 | - [Messaging](architecture/messaging.md) 7 | - [Components](architecture/components.md) 8 | - [Web](architecture/components/web.md) 9 | - [Usecases](architecture/components/usecases.md) 10 | - [Plugin](architecture/components/plugin.md) 11 | -------------------------------------------------------------------------------- /doc/src/architecture.md: -------------------------------------------------------------------------------- 1 | # Architecture 2 | 3 | The MSR system uses an _event-driven architecture_. 4 | 5 | It can be used in a viarity of contexts but typically 6 | it acts as a connector between high level management systems 7 | and low level devices like PLCs, I/O Systems or Sensors and Actuators 8 | (see [context section](architecture/context.md) for more details). 9 | 10 | The top-level components of MSR are referred to as _plugins_ 11 | that communicate asynchronously by sending and receiving messages through _channels_ 12 | (see [messaging section](architecture/messaging.md) for more details). 13 | 14 | Application specific _usecases_ are implemented in a separate layer 15 | that coordinates all the messages from the plugins 16 | but also from other more outer layers such as a web interface 17 | (see [components section](architecture/components.md) for more details). 18 | -------------------------------------------------------------------------------- /doc/src/architecture/components.md: -------------------------------------------------------------------------------- 1 | # Components 2 | 3 | There are mainly three kind of components: 4 | 5 | 1. **Web**: Connection to the outside world with a public API 6 | 2. **Usecases**: Implementation of application specific usecases 7 | 3. **Plugins**: Independent components that focus on specific aspects 8 | (e.g. recording or fieldbus communication) 9 | 10 | The diagram below shows the dependencies of the different component layers: 11 | 12 | ```plantuml 13 | @startuml 14 | 15 | {{#include ../../c4-plantuml/C4_Component.puml}} 16 | 17 | Boundary(MSR, "MSR Subsystem", "Platform: usually a (embedded) Linux") { 18 | Component(Web, "Web", "Public API") 19 | Component(Usecases, "Usecases", "Application specific") 20 | Component(Plugins, "Plugins", "Aspect specific") 21 | } 22 | 23 | Rel(Web, Usecases, "...") 24 | Rel(Usecases, Plugins, "...") 25 | 26 | @enduml 27 | ``` 28 | -------------------------------------------------------------------------------- /doc/src/architecture/components/plugin.md: -------------------------------------------------------------------------------- 1 | # Plugin 2 | 3 | ```plantuml 4 | @startuml 5 | 6 | {{#include ../../../c4-plantuml/C4_Component.puml}} 7 | 8 | Boundary(Plugin, "Plugin", "Message-driven actor") { 9 | Component(Api, "API", "Async") 10 | Component(Hooks, "Hooks", "Interfaces") 11 | Component(Extensions, "Extensions", "Data + Func") 12 | Boundary(Internal, "Internal") { 13 | Component(Context, "Context", "Internal State") 14 | Component(MessageLoop, "MessageLoop", "Sequential") 15 | Component(Tasks, "Tasks", "Concurrent") 16 | } 17 | } 18 | 19 | Rel(MessageLoop, Context, "update state") 20 | Rel(MessageLoop, Tasks, "manage lifecycle") 21 | Rel(Api, MessageLoop, "dispatch message") 22 | Rel(Internal, Api, "publish event") 23 | Rel(Internal, Hooks, "invoke") 24 | Rel(Extensions, Hooks, "implement") 25 | Rel(Api, Extensions, "include") 26 | 27 | @enduml 28 | ``` 29 | 30 | ## API 31 | 32 | ```plantuml 33 | @startuml 34 | 35 | {{#include ../../../c4-plantuml/C4_Component.puml}} 36 | 37 | Boundary(Api, "Plugin API") { 38 | Component(AsyncFnApi, "AsyncFn API", "Controller") 39 | Component(AsyncMsgApi, "AsyncMsg API", "Messaging") 40 | } 41 | 42 | Rel(AsyncFnApi, AsyncMsgApi, "use") 43 | 44 | @enduml 45 | ``` 46 | 47 | ## Extensions 48 | 49 | ```plantuml 50 | @startuml 51 | 52 | {{#include ../../../c4-plantuml/C4_Component.puml}} 53 | 54 | Boundary(Api, "Plugin API") 55 | 56 | Boundary(Extensions, "Plugin Extensions") { 57 | Component(ExtensionData, "ExtensionData", "Data types") 58 | Component(ExtensionImpl, "ExtensionImpl", "Trait impls") 59 | } 60 | 61 | Boundary(Hooks, "Plugin Hooks") 62 | 63 | Rel_Right(Api, ExtensionData, "include") 64 | Rel(ExtensionImpl, ExtensionData, "use") 65 | Rel_Right(ExtensionImpl, Hooks, "implement") 66 | 67 | @enduml 68 | ``` 69 | -------------------------------------------------------------------------------- /doc/src/architecture/components/usecases.md: -------------------------------------------------------------------------------- 1 | # Usecases 2 | 3 | ## Usecase API 4 | 5 | ```plantuml 6 | @startuml 7 | 8 | {{#include ../../../c4-plantuml/C4_Component.puml}} 9 | 10 | Component(UsecaseAPI, "Usecase API", "...") 11 | 12 | Component(CustomPlugin, "Custom Plugin", "Plugin") 13 | Component(RecorderPlugin, "MSR Recorder", "Plugin") 14 | Component(FieldbusPlugin, "MSR Fieldbus", "Plugin") 15 | Component(JournalPlugin, "MSR Journal", "Plugin") 16 | 17 | Rel(UsecaseAPI, CustomPlugin, "request/response") 18 | Rel(UsecaseAPI, FieldbusPlugin, "request/response") 19 | Rel(UsecaseAPI, RecorderPlugin, "request/response") 20 | Rel(UsecaseAPI, JournalPlugin, "request/response") 21 | 22 | @enduml 23 | ``` 24 | -------------------------------------------------------------------------------- /doc/src/architecture/components/web.md: -------------------------------------------------------------------------------- 1 | # Web 2 | 3 | Access to the service with its usecases is provided by a web API 4 | that translates and dispatches HTTP requests/responses. 5 | 6 | ```plantuml 7 | @startuml 8 | 9 | {{#include ../../../c4-plantuml/C4_Component.puml}} 10 | 11 | Boundary(Web, "Web Service", "...") { 12 | Component(Routes, "Routes", "...") 13 | Component(Handlers, "Handlers", "...") 14 | } 15 | 16 | Component(Usecases, "Usecases", "...") 17 | System_Ext(HTTPClient, "HTTP Client", "...") 18 | 19 | Rel_Right(HTTPClient, Routes, "HTTP Request") 20 | Rel(Routes, Handlers, "...") 21 | Rel_Right(Handlers, Usecases, "Command/Query") 22 | 23 | @enduml 24 | ``` 25 | -------------------------------------------------------------------------------- /doc/src/architecture/context.md: -------------------------------------------------------------------------------- 1 | # Context 2 | 3 | The diagrom below shows the _general_ context of the MSR system. 4 | 5 | ```plantuml 6 | @startuml 7 | 8 | {{#include ../../c4-plantuml/C4_Context.puml}} 9 | 10 | System(IIoT, "MSR System", "System for retrieving and processing fieldbus data.") 11 | System_Ext(IO, "PLC or I/O system", "I/O system accessible through a fieldbus interface.") 12 | System_Ext(Sensor, "Sensor", "Standalone sensor.") 13 | System_Ext(Actuator, "Actuator", "Standalone actuator.") 14 | System_Ext(SCADA, "SCADA", "Supervisory Control and Data Acquisition.") 15 | System_Ext(Gateway, "Gateway", "Internet gateway (e.g. GSM).") 16 | 17 | Rel_Right(IIoT, IO, "Cyclic and/or asynchronous data exchange") 18 | Rel(IIoT, Sensor, "Cyclic data query") 19 | Rel(IIoT, Actuator, "Cyclic and/or asynchronous commands") 20 | Rel(SCADA, IIoT, "Asynchronous data exchange") 21 | Rel(Gateway, IIoT, "Forward remote access") 22 | 23 | @enduml 24 | ``` 25 | -------------------------------------------------------------------------------- /doc/src/architecture/messaging.md: -------------------------------------------------------------------------------- 1 | # Messaging 2 | 3 | Messaging comes in two different flavors: 4 | 5 | - Peer-to-peer (P2P) 6 | - Broadcast 7 | 8 | Peer-to-peer messaging is bidirectional while broadcast messaging is strictly unidirectional. 9 | 10 | ## Peer-to-peer (P2P) 11 | 12 | Peer-to-peer messaging follows a request/response style. It involves _multiple producers_ that send requests to a _single consumer_. The consumer receives and processes requests. It sends a response or reply back to the same producer from which it has sent the request. 13 | 14 | Plugins receive requests in form of _commands_ and _queries_. Queries are supposed to be idempotent, i.e. neither do they affect the state of the plugin nor do they have any other, observable side effects. Commands may do not provide any of those guarantees. In contrast to the strict separation between commands and queries in a CQRS architecture both request types may have a response. 15 | 16 | All requests are processed sequentially one after another, i.e. message processing is not interrupted. Plugins may spawn additional _tasks_ internally to allow the execution of long running, concurrent operations. 17 | 18 | ## Broadcast 19 | 20 | Plugins notify other plugins or components by sending messages to broadcast channels. Each plugin provides at least one event channel for publishing information about the lifecycle, use case specific notifications, or notable incidents. 21 | 22 | Listeners receive broadcast messages by subscribing to the corresponding channel. Subscriptions might either be permanent or temporary depending on the use case. 23 | 24 | Broadcast channels have a limited capacity and slow receivers might miss messages if they are not able to keep up with the frequency of the publisher. 25 | 26 | ### Event Dispatch 27 | 28 | Plugins shall not receive and process events from other plugins directly. Instead intermediate _mediators_ are installed in between the plugins that receive events from one plugin and transform them into commands for another plugin. Those mediators are implemented as lightweight, asynchronous tasks. 29 | 30 | **Example**: 31 | On startup the broadcast event channel of each plugin is wired with an asynchronous task that is responsible for recording selected events in the journal. Those mediators know how to filter and transform received events into journal entries and send commands for writing those entries to the _Journal_ plugin. 32 | 33 | ```plantuml 34 | @startuml 35 | 36 | [Some Plugin] <> as SomePlugin 37 | [Journal] <> as JournalPlugin 38 | 39 | [Event Journaling] <> as SomeEventJournaling 40 | 41 | interface "Some Event" <> as SomeEvent 42 | 43 | SomePlugin -right-> SomeEvent : publish 44 | 45 | SomeEventJournaling .left.> SomeEvent : subscribe 46 | SomeEvent -right-> SomeEventJournaling : receive 47 | 48 | SomeEventJournaling -right-> JournalPlugin : write entry 49 | 50 | @enduml 51 | ``` 52 | -------------------------------------------------------------------------------- /doc/src/introduction.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | **MSR** is an open source industrial automation toolbox 4 | developed by [slowtec](https://slowtec.de). 5 | 6 | The MSR system is written in [Rust](https://rust-lang.org), 7 | a powerful language for creating reliable and performant software. 8 | 9 | Here are some key features of the **MSR** system: 10 | 11 | - **Lightweight:** 12 | Applications build with **MSR** are usually quite small. 13 | For example, a full-featured IIoT application with integrated web server 14 | and PLC connection can be bundled within a 2-6 MBytes executable. 15 | Also the memory usage is typically less than 20 MBytes. 16 | 17 | - **Easy deployable**: 18 | A complete automation application can be compiled into a single executable file 19 | that has no dependencies. 20 | This allows it to be easily copied to a target machine without worrying about 21 | shared libraries or other dependencies. 22 | 23 | - **Extendable**: 24 | The MSR system has a plug-in architecture that allows custom code to be combined 25 | with standard functions such as fieldbus communication or data logging. 26 | 27 | - **Hard real-time**: 28 | The MSR system can be used in use cases where hard real-time is required. 29 | For example, a dedicated realtime plugin could run in a separate process 30 | that is managed by a 31 | [real-time Linux](https://www.osadl.org/Realtime-Linux.projects-realtime-linux.0.html) 32 | kernel. 33 | 34 | - **Ready made plugins**: 35 | The MSR project offers several commonly used plugins such as the following: 36 | 37 | - **CSV Recording** - Record (cyclic) data within CSV files 38 | - **Journaling** - Record application specific events 39 | - **Modbus** - Communicate via Modbus RTU or Modbus TCP with other devices 40 | (This plugin is currently in development and not open sourced yet) 41 | 42 | - **Open Source**: 43 | The complete MSR system is licensed under either of 44 | [MIT License](http://opensource.org/licenses/MIT) or 45 | [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0) 46 | at your option. 47 | 48 | ## Contributing 49 | 50 | MSR is free and open source. 51 | You can find the source code on [GitHub](https://github.com/slowtec/msr) 52 | and issues and feature requests can be posted on the [GitHub issue tracker](https://github.com/slowtec/msr/issues). 53 | MSR relies on the community to fix bugs and add features: 54 | if you'd like to contribute, consider opening a [pull request](https://github.com/slowtec/msr/pulls). 55 | -------------------------------------------------------------------------------- /plugins/csv-event-journal/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "msr-plugin-csv-event-journal" 3 | description = "Industrial Automation Toolbox - CSV Event Journal Plugin" 4 | homepage.workspace = true 5 | repository.workspace = true 6 | license.workspace = true 7 | edition.workspace = true 8 | version.workspace = true 9 | rust-version.workspace = true 10 | 11 | [dependencies] 12 | anyhow = "1.0.75" 13 | log = "0.4.20" 14 | thiserror = "1.0.48" 15 | tokio = { version = "1.32.0", default-features = false, features = ["rt-multi-thread", "sync"] } 16 | 17 | # Workspace dependencies 18 | msr-core = { version = "=0.3.6", features = ["csv-event-journal"] } 19 | msr-plugin = "=0.3.6" 20 | -------------------------------------------------------------------------------- /plugins/csv-event-journal/src/api/command.rs: -------------------------------------------------------------------------------- 1 | use msr_core::event_journal::Entry; 2 | 3 | use crate::ResultSender; 4 | 5 | use super::{Config, RecordEntryOutcome, State}; 6 | 7 | #[derive(Debug)] 8 | pub enum Command { 9 | ReplaceConfig(ResultSender, Config), 10 | SwitchState(ResultSender<()>, State), 11 | RecordEntry(ResultSender, Entry), 12 | Shutdown(ResultSender<()>), 13 | } 14 | -------------------------------------------------------------------------------- /plugins/csv-event-journal/src/api/controller.rs: -------------------------------------------------------------------------------- 1 | use msr_core::event_journal::{Entry, StoredRecord}; 2 | 3 | use msr_plugin::{reply_channel, send_message_receive_result}; 4 | 5 | use crate::{MessageSender, PluginResult}; 6 | 7 | use super::{query, Command, Config, Query, RecordEntryOutcome, State, Status}; 8 | 9 | /// Remote controller for the plugin 10 | /// 11 | /// Wraps the message-based communication with the plugin 12 | /// into asynchronous functions. 13 | #[derive(Debug, Clone)] 14 | pub struct Controller { 15 | message_tx: MessageSender, 16 | } 17 | 18 | impl Controller { 19 | #[must_use] 20 | pub const fn new(message_tx: MessageSender) -> Self { 21 | Self { message_tx } 22 | } 23 | 24 | pub async fn command_replace_config(&self, new_config: Config) -> PluginResult { 25 | let (reply_tx, reply_rx) = reply_channel(); 26 | let command = Command::ReplaceConfig(reply_tx, new_config); 27 | 28 | send_message_receive_result(command, &self.message_tx, reply_rx).await 29 | } 30 | 31 | pub async fn command_switch_state(&self, new_state: State) -> PluginResult<()> { 32 | let (reply_tx, reply_rx) = reply_channel(); 33 | let command = Command::SwitchState(reply_tx, new_state); 34 | send_message_receive_result(command, &self.message_tx, reply_rx).await 35 | } 36 | 37 | pub async fn command_record_entry(&self, new_entry: Entry) -> PluginResult { 38 | let (reply_tx, reply_rx) = reply_channel(); 39 | let command = Command::RecordEntry(reply_tx, new_entry); 40 | 41 | send_message_receive_result(command, &self.message_tx, reply_rx).await 42 | } 43 | 44 | pub async fn command_shutdown(&self) -> PluginResult<()> { 45 | let (reply_tx, reply_rx) = reply_channel(); 46 | let command = Command::Shutdown(reply_tx); 47 | send_message_receive_result(command, &self.message_tx, reply_rx).await 48 | } 49 | 50 | pub async fn query_config(&self) -> PluginResult { 51 | let (reply_tx, reply_rx) = reply_channel(); 52 | let query = Query::Config(reply_tx); 53 | send_message_receive_result(query, &self.message_tx, reply_rx).await 54 | } 55 | 56 | pub async fn query_status(&self, request: query::StatusRequest) -> PluginResult { 57 | let (reply_tx, reply_rx) = reply_channel(); 58 | let query = Query::Status(reply_tx, request); 59 | send_message_receive_result(query, &self.message_tx, reply_rx).await 60 | } 61 | 62 | pub async fn query_recent_records( 63 | &self, 64 | request: query::RecentRecordsRequest, 65 | ) -> PluginResult> { 66 | let (reply_tx, reply_rx) = reply_channel(); 67 | let query = Query::RecentRecords(reply_tx, request); 68 | send_message_receive_result(query, &self.message_tx, reply_rx).await 69 | } 70 | 71 | pub async fn query_filter_records( 72 | &self, 73 | request: query::FilterRecordsRequest, 74 | ) -> PluginResult> { 75 | let (reply_tx, reply_rx) = reply_channel(); 76 | let query = Query::FilterRecords(reply_tx, request); 77 | send_message_receive_result(query, &self.message_tx, reply_rx).await 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /plugins/csv-event-journal/src/api/event.rs: -------------------------------------------------------------------------------- 1 | use super::{Config, State}; 2 | 3 | #[derive(Debug, Clone)] 4 | pub enum Event { 5 | Lifecycle(LifecycleEvent), 6 | Notification(NotificationEvent), 7 | Incident(IncidentEvent), 8 | } 9 | 10 | /// Common lifecycle events 11 | #[derive(Debug, Clone)] 12 | pub enum LifecycleEvent { 13 | Started, 14 | Stopped, 15 | ConfigChanged(Config), 16 | StateChanged(State), 17 | } 18 | 19 | /// Regular notifications for informational purposes 20 | #[derive(Debug, Clone)] 21 | pub struct NotificationEvent { 22 | // Empty placeholder 23 | } 24 | 25 | /// Unexpected incidents that might require (manual) intervention 26 | #[derive(Debug, Clone)] 27 | pub enum IncidentEvent { 28 | IoWriteError { 29 | os_code: Option, 30 | message: String, 31 | }, 32 | } 33 | -------------------------------------------------------------------------------- /plugins/csv-event-journal/src/api/mod.rs: -------------------------------------------------------------------------------- 1 | // Re-export internal types that are used in the public API 2 | pub use crate::internal::context::{ 3 | Config, EntryNotRecorded, EntryRecorded, RecordEntryOutcome, State, Status, 4 | }; 5 | 6 | pub mod controller; 7 | pub use self::controller::Controller; 8 | 9 | pub mod command; 10 | pub use self::command::Command; 11 | 12 | pub mod query; 13 | pub use self::query::Query; 14 | 15 | pub mod event; 16 | pub use self::event::Event; 17 | 18 | #[derive(Debug)] 19 | pub enum Message { 20 | Command(Command), 21 | Query(Query), 22 | } 23 | 24 | impl From for Message { 25 | fn from(command: Command) -> Self { 26 | Self::Command(command) 27 | } 28 | } 29 | 30 | impl From for Message { 31 | fn from(query: Query) -> Self { 32 | Self::Query(query) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /plugins/csv-event-journal/src/api/query.rs: -------------------------------------------------------------------------------- 1 | use std::num::NonZeroUsize; 2 | 3 | use msr_core::event_journal::{RecordFilter, StoredRecord}; 4 | 5 | use crate::ResultSender; 6 | 7 | use super::{Config, Status}; 8 | 9 | #[derive(Debug)] 10 | pub enum Query { 11 | Config(ResultSender), 12 | Status(ResultSender, StatusRequest), 13 | RecentRecords(ResultSender>, RecentRecordsRequest), 14 | FilterRecords(ResultSender>, FilterRecordsRequest), 15 | } 16 | 17 | #[derive(Debug, Clone)] 18 | pub struct StatusRequest { 19 | pub with_storage_statistics: bool, 20 | } 21 | 22 | #[derive(Debug, Clone)] 23 | pub struct RecentRecordsRequest { 24 | pub limit: NonZeroUsize, 25 | } 26 | 27 | #[derive(Debug, Clone)] 28 | pub struct FilterRecordsRequest { 29 | pub limit: NonZeroUsize, 30 | pub filter: RecordFilter, 31 | } 32 | -------------------------------------------------------------------------------- /plugins/csv-event-journal/src/internal/context.rs: -------------------------------------------------------------------------------- 1 | use std::{num::NonZeroUsize, path::PathBuf, result::Result as StdResult}; 2 | 3 | use msr_core::{ 4 | event_journal::{ 5 | csv::FileRecordStorage as CsvFileRecordStorage, DefaultRecordPreludeGenerator, Entry, 6 | Record, RecordFilter, RecordPreludeGenerator, RecordStorage, Result, Severity, 7 | StoredRecord, StoredRecordPrelude, 8 | }, 9 | storage::{ 10 | BinaryDataFormat, RecordStorageBase as _, RecordStorageWrite as _, StorageConfig, 11 | StorageStatus, 12 | }, 13 | }; 14 | 15 | #[derive(Debug, Clone, Copy, Eq, PartialEq)] 16 | pub enum State { 17 | Inactive, 18 | Active, 19 | } 20 | 21 | #[derive(Debug, Clone)] 22 | pub struct Status { 23 | pub state: State, 24 | pub storage: StorageStatus, 25 | } 26 | 27 | #[derive(Debug, Clone, Eq, PartialEq)] 28 | pub struct Config { 29 | pub severity_threshold: Severity, 30 | pub storage: StorageConfig, 31 | } 32 | 33 | pub(crate) struct Context { 34 | config: Config, 35 | 36 | state: State, 37 | 38 | storage: CsvFileRecordStorage, 39 | } 40 | 41 | #[derive(Debug)] 42 | pub struct EntryRecorded(pub StoredRecordPrelude); 43 | 44 | #[derive(Debug, Copy, Clone, Eq, PartialEq)] 45 | pub enum EntryNotRecorded { 46 | Inactive, 47 | SeverityBelowThreshold, 48 | } 49 | 50 | pub type RecordEntryOutcome = StdResult; 51 | 52 | impl Context { 53 | pub(crate) fn try_new( 54 | data_dir: PathBuf, 55 | file_name_prefix: String, 56 | binary_data_format: BinaryDataFormat, 57 | initial_config: Config, 58 | initial_state: State, 59 | ) -> Result { 60 | let storage = CsvFileRecordStorage::try_new( 61 | data_dir, 62 | file_name_prefix, 63 | binary_data_format, 64 | initial_config.storage.clone(), 65 | )?; 66 | Ok(Self { 67 | config: initial_config, 68 | state: initial_state, 69 | storage, 70 | }) 71 | } 72 | 73 | pub(crate) fn config(&self) -> &Config { 74 | &self.config 75 | } 76 | 77 | pub(crate) fn state(&self) -> State { 78 | self.state 79 | } 80 | 81 | pub(crate) fn status(&mut self, with_storage_statistics: bool) -> Result { 82 | let storage_statistics = if with_storage_statistics { 83 | Some(self.storage.report_statistics()?) 84 | } else { 85 | None 86 | }; 87 | let storage = StorageStatus { 88 | descriptor: self.storage.descriptor().clone(), 89 | statistics: storage_statistics, 90 | }; 91 | Ok(Status { 92 | state: self.state(), 93 | storage, 94 | }) 95 | } 96 | 97 | pub(crate) fn recent_records(&mut self, limit: NonZeroUsize) -> Result> { 98 | self.storage.recent_records(limit) 99 | } 100 | 101 | pub(crate) fn filter_records( 102 | &mut self, 103 | limit: NonZeroUsize, 104 | filter: RecordFilter, 105 | ) -> Result> { 106 | self.storage.filter_records(limit, filter) 107 | } 108 | 109 | /// Switch the current configuration 110 | /// 111 | /// Returns the previous configuration. 112 | pub(crate) fn replace_config(&mut self, new_config: Config) -> Result { 113 | if self.config == new_config { 114 | return Ok(new_config); 115 | } 116 | log::debug!("Replacing config: {:?} -> {:?}", self.config, new_config); 117 | self.storage.replace_config(new_config.storage.clone()); 118 | Ok(std::mem::replace(&mut self.config, new_config)) 119 | } 120 | 121 | /// Switch the current state 122 | /// 123 | /// Returns the previous state. 124 | pub(crate) fn switch_state(&mut self, new_state: State) -> Result { 125 | if self.state == new_state { 126 | return Ok(new_state); 127 | } 128 | log::debug!("Switching state: {:?} -> {:?}", self.state, new_state); 129 | Ok(std::mem::replace(&mut self.state, new_state)) 130 | } 131 | 132 | pub(crate) fn record_entry(&mut self, new_entry: Entry) -> Result { 133 | match self.state { 134 | State::Inactive => { 135 | log::debug!("Discarding new entry while inactive: {:?}", new_entry); 136 | Ok(Err(EntryNotRecorded::Inactive)) 137 | } 138 | State::Active => { 139 | if new_entry.severity < self.config.severity_threshold { 140 | log::debug!( 141 | "Discarding new entry below severity threshold: {:?}", 142 | new_entry 143 | ); 144 | return Ok(Err(EntryNotRecorded::SeverityBelowThreshold)); 145 | } 146 | DefaultRecordPreludeGenerator 147 | .generate_prelude() 148 | .map(|(created_at, prelude)| { 149 | ( 150 | created_at, 151 | Record { 152 | prelude, 153 | entry: new_entry, 154 | }, 155 | ) 156 | }) 157 | .and_then(|(created_at, recorded_entry)| { 158 | log::debug!("Recording entry: {:?}", recorded_entry); 159 | let prelude = StoredRecordPrelude { 160 | id: recorded_entry.prelude.id.clone(), 161 | created_at: created_at.system_time(), 162 | }; 163 | self.storage 164 | .append_record(&created_at, recorded_entry) 165 | .map(|_created_at_offset| Ok(EntryRecorded(prelude))) 166 | .map_err(msr_core::event_journal::Error::Storage) 167 | }) 168 | } 169 | } 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /plugins/csv-event-journal/src/internal/invoke_context_from_message_loop.rs: -------------------------------------------------------------------------------- 1 | use tokio::task; 2 | 3 | use msr_core::event_journal::{Entry, Error, StoredRecord}; 4 | 5 | use msr_plugin::send_reply; 6 | 7 | use crate::{ 8 | api::{ 9 | event::{IncidentEvent, LifecycleEvent}, 10 | query, Config, Event, RecordEntryOutcome, State, Status, 11 | }, 12 | EventPubSub, ResultSender, 13 | }; 14 | 15 | use super::context::Context; 16 | 17 | pub(crate) fn command_replace_config( 18 | context: &mut Context, 19 | event_pubsub: &EventPubSub, 20 | reply_tx: ResultSender, 21 | new_config: Config, 22 | ) { 23 | let result = task::block_in_place(|| { 24 | context.replace_config(new_config.clone()).map_err(|err| { 25 | log::warn!("Failed to replace configuration: {}", err); 26 | err 27 | }) 28 | }) 29 | .map(|old_config| { 30 | let event = Event::Lifecycle(LifecycleEvent::ConfigChanged(new_config)); 31 | event_pubsub.publish_event(event); 32 | old_config 33 | }); 34 | send_reply(reply_tx, result.map_err(Into::into)); 35 | } 36 | 37 | pub(crate) fn command_switch_state( 38 | context: &mut Context, 39 | event_pubsub: &EventPubSub, 40 | reply_tx: ResultSender<()>, 41 | new_state: State, 42 | ) { 43 | let result = task::block_in_place(|| { 44 | context.switch_state(new_state).map_err(|err| { 45 | log::warn!("Failed to switch state: {}", err); 46 | err 47 | }) 48 | }) 49 | .map(|_old_state| { 50 | let event = Event::Lifecycle(LifecycleEvent::StateChanged(new_state)); 51 | event_pubsub.publish_event(event); 52 | }); 53 | send_reply(reply_tx, result.map_err(Into::into)); 54 | } 55 | 56 | pub(crate) fn command_record_entry( 57 | context: &mut Context, 58 | event_pubsub: &EventPubSub, 59 | reply_tx: ResultSender, 60 | new_entry: Entry, 61 | ) { 62 | let result = task::block_in_place(|| { 63 | context.record_entry(new_entry).map_err(|err| { 64 | log::warn!("Failed create new entry: {}", err); 65 | err 66 | }) 67 | }) 68 | .map_err(|err| { 69 | if let Error::Storage(msr_core::storage::Error::Io(err)) = &err { 70 | let os_code = err.raw_os_error(); 71 | let message = err.to_string(); 72 | let event = Event::Incident(IncidentEvent::IoWriteError { os_code, message }); 73 | event_pubsub.publish_event(event); 74 | } 75 | err 76 | }); 77 | send_reply(reply_tx, result.map_err(Into::into)); 78 | } 79 | 80 | pub(crate) fn command_shutdown(_context: &mut Context, reply_tx: ResultSender<()>) { 81 | send_reply(reply_tx, Ok(())); 82 | } 83 | 84 | pub(crate) fn query_config(context: &Context, reply_tx: ResultSender) { 85 | let result = task::block_in_place(|| Ok(context.config().clone())); 86 | send_reply(reply_tx, result); 87 | } 88 | 89 | #[allow(clippy::needless_pass_by_value)] 90 | pub(crate) fn query_status( 91 | context: &mut Context, 92 | reply_tx: ResultSender, 93 | request: query::StatusRequest, 94 | ) { 95 | let result = task::block_in_place(|| { 96 | let query::StatusRequest { 97 | with_storage_statistics, 98 | } = request; 99 | context.status(with_storage_statistics).map_err(|err| { 100 | log::warn!("Failed to query status: {}", err); 101 | err 102 | }) 103 | }); 104 | send_reply(reply_tx, result.map_err(Into::into)); 105 | } 106 | 107 | #[allow(clippy::needless_pass_by_value)] 108 | pub(crate) fn query_recent_records( 109 | context: &mut Context, 110 | reply_tx: ResultSender>, 111 | request: query::RecentRecordsRequest, 112 | ) { 113 | let result = task::block_in_place(|| { 114 | let query::RecentRecordsRequest { limit } = request; 115 | context.recent_records(limit).map_err(|err| { 116 | log::warn!("Failed to query recent records: {}", err); 117 | err 118 | }) 119 | }); 120 | send_reply(reply_tx, result.map_err(Into::into)); 121 | } 122 | 123 | pub(crate) fn query_filter_records( 124 | context: &mut Context, 125 | reply_tx: ResultSender>, 126 | request: query::FilterRecordsRequest, 127 | ) { 128 | let result = task::block_in_place(|| { 129 | let query::FilterRecordsRequest { limit, filter } = request; 130 | context.filter_records(limit, filter).map_err(|err| { 131 | log::warn!("Failed to query filtered records: {}", err); 132 | err 133 | }) 134 | }); 135 | send_reply(reply_tx, result.map_err(Into::into)); 136 | } 137 | -------------------------------------------------------------------------------- /plugins/csv-event-journal/src/internal/message_loop.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use msr_core::storage::BinaryDataFormat; 4 | use msr_plugin::{message_channel, MessageLoop}; 5 | 6 | use crate::{ 7 | api::{event::LifecycleEvent, Command, Config, Event, Message, Query, State}, 8 | EventPubSub, MessageSender, Result, 9 | }; 10 | 11 | use super::{context::Context, invoke_context_from_message_loop}; 12 | 13 | pub(crate) fn create_message_loop( 14 | data_dir: PathBuf, 15 | file_name_prefix: String, 16 | event_pubsub: EventPubSub, 17 | binary_data_format: BinaryDataFormat, 18 | initial_config: Config, 19 | initial_state: State, 20 | ) -> Result<(MessageLoop, MessageSender)> { 21 | let (message_tx, mut message_rx) = message_channel(); 22 | let mut context = Context::try_new( 23 | data_dir, 24 | file_name_prefix, 25 | binary_data_format, 26 | initial_config, 27 | initial_state, 28 | )?; 29 | let message_loop = async move { 30 | let mut exit_message_loop = false; 31 | log::info!("Starting message loop"); 32 | event_pubsub.publish_event(Event::Lifecycle(LifecycleEvent::Started)); 33 | while let Some(msg) = message_rx.recv().await { 34 | match msg { 35 | Message::Command(command) => { 36 | log::trace!("Received command {:?}", command); 37 | match command { 38 | Command::ReplaceConfig(reply_tx, new_config) => { 39 | invoke_context_from_message_loop::command_replace_config( 40 | &mut context, 41 | &event_pubsub, 42 | reply_tx, 43 | new_config, 44 | ); 45 | } 46 | Command::SwitchState(reply_tx, new_state) => { 47 | invoke_context_from_message_loop::command_switch_state( 48 | &mut context, 49 | &event_pubsub, 50 | reply_tx, 51 | new_state, 52 | ); 53 | } 54 | Command::RecordEntry(reply_tx, new_entry) => { 55 | invoke_context_from_message_loop::command_record_entry( 56 | &mut context, 57 | &event_pubsub, 58 | reply_tx, 59 | new_entry, 60 | ); 61 | } 62 | Command::Shutdown(reply_tx) => { 63 | invoke_context_from_message_loop::command_shutdown( 64 | &mut context, 65 | reply_tx, 66 | ); 67 | exit_message_loop = true; 68 | } 69 | } 70 | } 71 | Message::Query(query) => { 72 | log::debug!("Received query {:?}", query); 73 | match query { 74 | Query::Config(reply_tx) => { 75 | invoke_context_from_message_loop::query_config(&context, reply_tx); 76 | } 77 | Query::Status(reply_tx, request) => { 78 | invoke_context_from_message_loop::query_status( 79 | &mut context, 80 | reply_tx, 81 | request, 82 | ); 83 | } 84 | Query::RecentRecords(reply_tx, request) => { 85 | invoke_context_from_message_loop::query_recent_records( 86 | &mut context, 87 | reply_tx, 88 | request, 89 | ); 90 | } 91 | Query::FilterRecords(reply_tx, request) => { 92 | invoke_context_from_message_loop::query_filter_records( 93 | &mut context, 94 | reply_tx, 95 | request, 96 | ); 97 | } 98 | } 99 | } 100 | } 101 | if exit_message_loop { 102 | log::info!("Exiting message loop"); 103 | break; 104 | } 105 | } 106 | log::info!("Message loop terminated"); 107 | event_pubsub.publish_event(Event::Lifecycle(LifecycleEvent::Stopped)); 108 | }; 109 | Ok((Box::pin(message_loop), message_tx)) 110 | } 111 | -------------------------------------------------------------------------------- /plugins/csv-event-journal/src/internal/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod context; 2 | pub(crate) mod message_loop; 3 | 4 | mod invoke_context_from_message_loop; 5 | -------------------------------------------------------------------------------- /plugins/csv-event-journal/src/lib.rs: -------------------------------------------------------------------------------- 1 | // FIXME: Enable `deny(missing_docs)` before release 2 | //#![deny(missing_docs)] 3 | 4 | #![warn(rust_2018_idioms)] 5 | #![warn(rust_2021_compatibility)] 6 | #![warn(missing_debug_implementations)] 7 | #![warn(unreachable_pub)] 8 | #![warn(unsafe_code)] 9 | #![warn(rustdoc::broken_intra_doc_links)] 10 | #![warn(clippy::pedantic)] 11 | // Additional restrictions 12 | #![warn(clippy::clone_on_ref_ptr)] 13 | #![warn(clippy::self_named_module_files)] 14 | // Exceptions 15 | #![allow(clippy::default_trait_access)] 16 | #![allow(clippy::module_name_repetitions)] 17 | #![allow(clippy::missing_errors_doc)] // TODO 18 | #![allow(clippy::missing_panics_doc)] // TODO 19 | #![allow(clippy::unnecessary_wraps)] // TODO 20 | 21 | use std::{ 22 | io::Error as IoError, 23 | num::{NonZeroU32, NonZeroU64}, 24 | path::PathBuf, 25 | }; 26 | 27 | use thiserror::Error; 28 | 29 | use msr_core::{ 30 | event_journal::Severity, 31 | storage::{BinaryDataFormat, MemorySize, StorageConfig, StorageSegmentConfig, TimeInterval}, 32 | }; 33 | 34 | use msr_plugin::EventPublisherIndex; 35 | 36 | pub mod api; 37 | 38 | mod internal; 39 | use self::internal::message_loop::create_message_loop; 40 | 41 | #[derive(Debug, Clone)] 42 | pub struct Environment { 43 | pub event_publisher_index: EventPublisherIndex, 44 | 45 | /// Directory for storing CSV data 46 | pub data_dir: PathBuf, 47 | 48 | pub custom_file_name_prefix: Option, 49 | } 50 | 51 | #[must_use] 52 | pub fn default_storage_config() -> StorageConfig { 53 | StorageConfig { 54 | retention_time: TimeInterval::Days(NonZeroU32::new(180).unwrap()), // 180 days 55 | segmentation: StorageSegmentConfig { 56 | time_interval: TimeInterval::Days(NonZeroU32::new(1).unwrap()), // daily 57 | size_limit: MemorySize::Bytes(NonZeroU64::new(1_048_576).unwrap()), // 1 MiB 58 | }, 59 | } 60 | } 61 | 62 | #[must_use] 63 | pub fn default_config() -> api::Config { 64 | api::Config { 65 | severity_threshold: Severity::Information, 66 | storage: default_storage_config(), 67 | } 68 | } 69 | 70 | #[derive(Debug, Clone, PartialEq, Eq)] 71 | pub struct PluginSetup { 72 | pub binary_data_format: BinaryDataFormat, 73 | pub initial_config: api::Config, 74 | pub initial_state: api::State, 75 | } 76 | 77 | impl Default for PluginSetup { 78 | fn default() -> Self { 79 | Self { 80 | binary_data_format: BinaryDataFormat::Utf8, // assume JSON/UTF-8 data 81 | initial_config: default_config(), 82 | initial_state: api::State::Inactive, 83 | } 84 | } 85 | } 86 | 87 | #[derive(Error, Debug)] 88 | pub enum Error { 89 | #[error("missing config")] 90 | MissingConfig, 91 | 92 | #[error("invalid state")] 93 | InvalidState, 94 | 95 | // TODO: Rename this variant? 96 | #[error(transparent)] 97 | MsrCore(#[from] msr_core::event_journal::Error), 98 | 99 | #[error(transparent)] 100 | Io(#[from] IoError), 101 | 102 | #[error(transparent)] 103 | Other(#[from] anyhow::Error), 104 | } 105 | 106 | pub type Result = std::result::Result; 107 | 108 | pub type PluginError = msr_plugin::PluginError; 109 | pub type PluginResult = msr_plugin::PluginResult; 110 | 111 | pub type MessageSender = msr_plugin::MessageSender; 112 | pub type MessageReceiver = msr_plugin::MessageReceiver; 113 | 114 | pub type ResultSender = msr_plugin::ResultSender; 115 | pub type ResultReceiver = msr_plugin::ResultReceiver; 116 | 117 | pub type PublishedEvent = msr_plugin::PublishedEvent; 118 | pub type EventReceiver = msr_plugin::EventReceiver; 119 | type EventPubSub = msr_plugin::EventPubSub; 120 | 121 | pub type Plugin = msr_plugin::PluginContainer; 122 | pub type PluginPorts = msr_plugin::PluginPorts; 123 | 124 | pub const DEFAULT_FILE_NAME_PREFIX: &str = "event_journal_records_"; 125 | 126 | pub fn create_plugin( 127 | environment: Environment, 128 | plugin_setup: PluginSetup, 129 | event_channel_capacity: usize, 130 | ) -> Result { 131 | let Environment { 132 | event_publisher_index, 133 | data_dir, 134 | custom_file_name_prefix, 135 | } = environment; 136 | let PluginSetup { 137 | binary_data_format, 138 | initial_config, 139 | initial_state, 140 | } = plugin_setup; 141 | let (event_pubsub, event_subscriber) = 142 | EventPubSub::new(event_publisher_index, event_channel_capacity); 143 | let file_name_prefix = 144 | custom_file_name_prefix.unwrap_or_else(|| DEFAULT_FILE_NAME_PREFIX.to_owned()); 145 | let (message_loop, message_tx) = create_message_loop( 146 | data_dir, 147 | file_name_prefix, 148 | event_pubsub, 149 | binary_data_format, 150 | initial_config, 151 | initial_state, 152 | )?; 153 | Ok(Plugin { 154 | ports: PluginPorts { 155 | message_tx, 156 | event_subscriber, 157 | }, 158 | message_loop, 159 | }) 160 | } 161 | -------------------------------------------------------------------------------- /plugins/csv-register-recorder/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "msr-plugin-csv-register-recorder" 3 | description = "slowtec Industrial Automation Runtime - CSV Register Recorder Plugin" 4 | homepage.workspace = true 5 | repository.workspace = true 6 | license.workspace = true 7 | edition.workspace = true 8 | version.workspace = true 9 | rust-version.workspace = true 10 | 11 | [dependencies] 12 | anyhow = "1.0.75" 13 | bs58 = { version = "0.5.0", default-features = false, features = ["std"] } 14 | log = "0.4.20" 15 | thiserror = "1.0.48" 16 | tokio = { version = "1.32.0", default-features = false, features = ["rt-multi-thread"] } 17 | 18 | # Workspace dependencies 19 | msr-core = { version = "=0.3.6", features = ["csv-register-recorder"] } 20 | msr-plugin = "=0.3.6" 21 | -------------------------------------------------------------------------------- /plugins/csv-register-recorder/src/api/command.rs: -------------------------------------------------------------------------------- 1 | use crate::ResultSender; 2 | 3 | use super::{ObservedRegisterValues, RegisterGroupId}; 4 | 5 | #[derive(Debug)] 6 | pub enum Command { 7 | ReplaceConfig(ResultSender, Config), 8 | ReplaceRegisterGroupConfig( 9 | ResultSender>, 10 | RegisterGroupId, 11 | RegisterGroupConfig, 12 | ), 13 | SwitchState(ResultSender<()>, State), 14 | RecordObservedRegisterGroupValues(ResultSender<()>, RegisterGroupId, ObservedRegisterValues), 15 | Shutdown(ResultSender<()>), 16 | // TODO: Replace pseudo smoke test command with integration test 17 | SmokeTest(ResultSender<()>), 18 | } 19 | 20 | use super::{Config, RegisterGroupConfig, State}; 21 | -------------------------------------------------------------------------------- /plugins/csv-register-recorder/src/api/controller.rs: -------------------------------------------------------------------------------- 1 | use msr_plugin::{reply_channel, send_message_receive_result}; 2 | 3 | use crate::{MessageSender, PluginResult}; 4 | 5 | use super::{ 6 | query, Command, Config, ObservedRegisterValues, Query, RegisterGroupConfig, RegisterGroupId, 7 | State, Status, StoredRegisterRecord, 8 | }; 9 | 10 | /// Remote controller for the plugin 11 | /// 12 | /// Wraps the message-based communication with the plugin 13 | /// into asynchronous functions. 14 | #[derive(Debug, Clone)] 15 | pub struct Controller { 16 | message_tx: MessageSender, 17 | } 18 | 19 | impl Controller { 20 | #[must_use] 21 | pub const fn new(message_tx: MessageSender) -> Self { 22 | Self { message_tx } 23 | } 24 | 25 | pub async fn command_replace_config(&self, new_config: Config) -> PluginResult { 26 | let (reply_tx, reply_rx) = reply_channel(); 27 | let command = Command::ReplaceConfig(reply_tx, new_config); 28 | send_message_receive_result(command, &self.message_tx, reply_rx).await 29 | } 30 | 31 | pub async fn command_replace_register_group_config( 32 | &self, 33 | register_group_id: RegisterGroupId, 34 | new_config: RegisterGroupConfig, 35 | ) -> PluginResult> { 36 | let (reply_tx, reply_rx) = reply_channel(); 37 | let command = Command::ReplaceRegisterGroupConfig(reply_tx, register_group_id, new_config); 38 | send_message_receive_result(command, &self.message_tx, reply_rx).await 39 | } 40 | 41 | pub async fn command_switch_state(&self, new_state: State) -> PluginResult<()> { 42 | let (reply_tx, reply_rx) = reply_channel(); 43 | let command = Command::SwitchState(reply_tx, new_state); 44 | send_message_receive_result(command, &self.message_tx, reply_rx).await 45 | } 46 | 47 | pub async fn command_record_observed_register_group_values( 48 | &self, 49 | register_group_id: RegisterGroupId, 50 | observed_register_values: ObservedRegisterValues, 51 | ) -> PluginResult<()> { 52 | let (reply_tx, reply_rx) = reply_channel(); 53 | let command = Command::RecordObservedRegisterGroupValues( 54 | reply_tx, 55 | register_group_id, 56 | observed_register_values, 57 | ); 58 | send_message_receive_result(command, &self.message_tx, reply_rx).await 59 | } 60 | 61 | pub async fn command_shutdown(&self) -> PluginResult<()> { 62 | let (reply_tx, reply_rx) = reply_channel(); 63 | let command = Command::Shutdown(reply_tx); 64 | send_message_receive_result(command, &self.message_tx, reply_rx).await 65 | } 66 | 67 | pub async fn command_smoke_test(&self) -> PluginResult<()> { 68 | let (reply_tx, reply_rx) = reply_channel(); 69 | let command = Command::SmokeTest(reply_tx); 70 | send_message_receive_result(command, &self.message_tx, reply_rx).await 71 | } 72 | 73 | pub async fn query_config(&self) -> PluginResult { 74 | let (reply_tx, reply_rx) = reply_channel(); 75 | let query = Query::Config(reply_tx); 76 | send_message_receive_result(query, &self.message_tx, reply_rx).await 77 | } 78 | 79 | pub async fn query_register_group_config( 80 | &self, 81 | register_group_id: RegisterGroupId, 82 | ) -> PluginResult> { 83 | let (reply_tx, reply_rx) = reply_channel(); 84 | let query = Query::RegisterGroupConfig(reply_tx, register_group_id); 85 | send_message_receive_result(query, &self.message_tx, reply_rx).await 86 | } 87 | 88 | pub async fn query_status(&self, request: query::StatusRequest) -> PluginResult { 89 | let (reply_tx, reply_rx) = reply_channel(); 90 | let query = Query::Status(reply_tx, request); 91 | send_message_receive_result(query, &self.message_tx, reply_rx).await 92 | } 93 | 94 | pub async fn query_recent_records( 95 | &self, 96 | register_group_id: RegisterGroupId, 97 | req: query::RecentRecordsRequest, 98 | ) -> PluginResult> { 99 | let (reply_tx, reply_rx) = reply_channel(); 100 | let query = Query::RecentRecords(reply_tx, register_group_id, req); 101 | send_message_receive_result(query, &self.message_tx, reply_rx).await 102 | } 103 | 104 | pub async fn query_filter_records( 105 | &self, 106 | register_group_id: RegisterGroupId, 107 | req: query::FilterRecordsRequest, 108 | ) -> PluginResult> { 109 | let (reply_tx, reply_rx) = reply_channel(); 110 | let query = Query::FilterRecords(reply_tx, register_group_id, req); 111 | send_message_receive_result(query, &self.message_tx, reply_rx).await 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /plugins/csv-register-recorder/src/api/event.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use super::{Config, RegisterGroupId, State}; 4 | 5 | #[derive(Debug, Clone)] 6 | pub enum Event { 7 | Lifecycle(LifecycleEvent), 8 | Notification(NotificationEvent), 9 | Incident(IncidentEvent), 10 | } 11 | 12 | /// Common lifecycle events 13 | #[derive(Debug, Clone)] 14 | pub enum LifecycleEvent { 15 | Started, 16 | Stopped, 17 | ConfigChanged(Config), 18 | StateChanged(State), 19 | } 20 | 21 | /// Regular notifications 22 | #[derive(Debug, Clone)] 23 | pub enum NotificationEvent { 24 | DataDirectoryCreated { 25 | register_group_id: RegisterGroupId, 26 | fs_path: PathBuf, 27 | }, 28 | } 29 | 30 | /// Unexpected incidents that might require intervention 31 | #[derive(Debug, Clone)] 32 | pub enum IncidentEvent { 33 | IoWriteError { 34 | os_code: Option, 35 | message: String, 36 | }, 37 | } 38 | -------------------------------------------------------------------------------- /plugins/csv-register-recorder/src/api/mod.rs: -------------------------------------------------------------------------------- 1 | // Re-export internal types that are used in the public API 2 | pub use crate::internal::{ 3 | context::{Config, RegisterGroupConfig, RegisterGroupStatus, State, Status}, 4 | register::{ 5 | GroupId as RegisterGroupId, GroupIdValue as RegisterGroupIdValue, ObservedRegisterValues, 6 | Record as RegisterRecord, StoredRecord as StoredRegisterRecord, Type as RegisterType, 7 | Value as RegisterValue, 8 | }, 9 | }; 10 | 11 | pub mod controller; 12 | pub use self::controller::Controller; 13 | 14 | pub mod command; 15 | pub use self::command::Command; 16 | 17 | pub mod query; 18 | pub use self::query::Query; 19 | 20 | pub mod event; 21 | pub use self::event::Event; 22 | 23 | #[derive(Debug)] 24 | pub enum Message { 25 | Command(Command), 26 | Query(Query), 27 | } 28 | 29 | impl From for Message { 30 | fn from(command: Command) -> Self { 31 | Self::Command(command) 32 | } 33 | } 34 | 35 | impl From for Message { 36 | fn from(query: Query) -> Self { 37 | Self::Query(query) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /plugins/csv-register-recorder/src/api/query.rs: -------------------------------------------------------------------------------- 1 | use std::num::NonZeroUsize; 2 | 3 | use msr_core::storage::RecordPreludeFilter; 4 | 5 | use crate::ResultSender; 6 | 7 | use super::{Config, RegisterGroupConfig, RegisterGroupId, Status, StoredRegisterRecord}; 8 | 9 | #[derive(Debug, Clone)] 10 | pub struct RecentRecordsRequest { 11 | pub limit: NonZeroUsize, 12 | } 13 | 14 | #[derive(Debug, Clone)] 15 | pub struct FilterRecordsRequest { 16 | pub limit: NonZeroUsize, 17 | pub filter: RecordPreludeFilter, 18 | } 19 | 20 | #[derive(Debug, Clone, Default)] 21 | pub struct StatusRequest { 22 | pub with_register_groups: bool, 23 | pub with_storage_statistics: bool, 24 | } 25 | 26 | #[derive(Debug)] 27 | pub enum Query { 28 | Config(ResultSender), 29 | RegisterGroupConfig(ResultSender>, RegisterGroupId), 30 | Status(ResultSender, StatusRequest), 31 | RecentRecords( 32 | ResultSender>, 33 | RegisterGroupId, 34 | RecentRecordsRequest, 35 | ), 36 | FilterRecords( 37 | ResultSender>, 38 | RegisterGroupId, 39 | FilterRecordsRequest, 40 | ), 41 | } 42 | -------------------------------------------------------------------------------- /plugins/csv-register-recorder/src/internal/invoke_context_from_message_loop.rs: -------------------------------------------------------------------------------- 1 | use tokio::task; 2 | 3 | use msr_plugin::send_reply; 4 | 5 | use crate::{ 6 | api::{ 7 | event::LifecycleEvent, query, Config, Event, ObservedRegisterValues, RegisterGroupConfig, 8 | RegisterGroupId, State, Status, StoredRegisterRecord, 9 | }, 10 | EventPubSub, ResultSender, 11 | }; 12 | 13 | use super::context::Context; 14 | 15 | pub(crate) fn command_replace_config( 16 | context: &mut Context, 17 | event_pubsub: &EventPubSub, 18 | reply_tx: ResultSender, 19 | new_config: Config, 20 | ) { 21 | let response = task::block_in_place(|| { 22 | context.replace_config(new_config.clone()).map_err(|err| { 23 | log::warn!("Failed to replace configuration: {}", err); 24 | err 25 | }) 26 | }) 27 | .map(|old_config| { 28 | let event = Event::Lifecycle(LifecycleEvent::ConfigChanged(new_config)); 29 | event_pubsub.publish_event(event); 30 | old_config 31 | }); 32 | send_reply(reply_tx, response); 33 | } 34 | 35 | #[allow(clippy::needless_pass_by_value)] 36 | pub(crate) fn command_replace_register_group_config( 37 | context: &mut Context, 38 | event_pubsub: &EventPubSub, 39 | reply_tx: ResultSender>, 40 | register_group_id: RegisterGroupId, 41 | new_config: RegisterGroupConfig, 42 | ) { 43 | let response = task::block_in_place(|| { 44 | context 45 | .replace_register_group_config(register_group_id.clone(), new_config) 46 | .map_err(|err| { 47 | log::warn!( 48 | "Failed replace configuration of register group {}: {}", 49 | register_group_id, 50 | err 51 | ); 52 | err 53 | }) 54 | }) 55 | .map(|old_config| { 56 | let event = Event::Lifecycle(LifecycleEvent::ConfigChanged(context.config().clone())); 57 | event_pubsub.publish_event(event); 58 | old_config 59 | }); 60 | send_reply(reply_tx, response); 61 | } 62 | 63 | pub(crate) fn command_switch_state( 64 | context: &mut Context, 65 | event_pubsub: &EventPubSub, 66 | reply_tx: ResultSender<()>, 67 | new_state: State, 68 | ) { 69 | let response = task::block_in_place(|| { 70 | context.switch_state(new_state).map_err(|err| { 71 | log::warn!("Failed to switch state: {}", err); 72 | err 73 | }) 74 | }) 75 | .map(|_old_state| { 76 | let event = Event::Lifecycle(LifecycleEvent::StateChanged(new_state)); 77 | event_pubsub.publish_event(event); 78 | }); 79 | send_reply(reply_tx, response); 80 | } 81 | 82 | #[allow(clippy::needless_pass_by_value)] 83 | pub(crate) fn command_record_observed_register_group_values( 84 | context: &mut Context, 85 | reply_tx: ResultSender<()>, 86 | register_group_id: RegisterGroupId, 87 | observed_register_values: ObservedRegisterValues, 88 | ) { 89 | let response = task::block_in_place(|| { 90 | context 91 | .record_observed_register_group_values(®ister_group_id, observed_register_values) 92 | .map(|_| ()) 93 | .map_err(|err| { 94 | log::warn!("Failed record new observation: {}", err); 95 | err 96 | }) 97 | }); 98 | send_reply(reply_tx, response); 99 | } 100 | 101 | pub(crate) fn command_shutdown(reply_tx: ResultSender<()>) { 102 | send_reply(reply_tx, Ok(())); 103 | } 104 | 105 | pub(crate) fn query_config(context: &Context, reply_tx: ResultSender) { 106 | let response = task::block_in_place(|| Ok(context.config().clone())); 107 | send_reply(reply_tx, response); 108 | } 109 | 110 | pub(crate) fn query_register_group_config( 111 | context: &Context, 112 | reply_tx: ResultSender>, 113 | register_group_id: &RegisterGroupId, 114 | ) { 115 | let response = 116 | task::block_in_place(|| Ok(context.register_group_config(register_group_id).cloned())); 117 | send_reply(reply_tx, response); 118 | } 119 | 120 | pub(crate) fn query_status( 121 | context: &mut Context, 122 | reply_tx: ResultSender, 123 | request: &query::StatusRequest, 124 | ) { 125 | let response = task::block_in_place(|| { 126 | let query::StatusRequest { 127 | with_register_groups, 128 | with_storage_statistics, 129 | } = request; 130 | context 131 | .status(*with_register_groups, *with_storage_statistics) 132 | .map_err(|err| { 133 | log::warn!("Failed to query status: {}", err); 134 | err 135 | }) 136 | }); 137 | send_reply(reply_tx, response); 138 | } 139 | 140 | #[allow(clippy::needless_pass_by_value)] 141 | pub(crate) fn query_recent_records( 142 | context: &mut Context, 143 | reply_tx: ResultSender>, 144 | register_group_id: &RegisterGroupId, 145 | request: query::RecentRecordsRequest, 146 | ) { 147 | let response = task::block_in_place(|| { 148 | let query::RecentRecordsRequest { limit } = request; 149 | context 150 | .recent_records(register_group_id, limit) 151 | .map_err(|err| { 152 | log::warn!("Failed to query recent records: {}", err); 153 | err 154 | }) 155 | }); 156 | send_reply(reply_tx, response); 157 | } 158 | 159 | pub(crate) fn query_filter_records( 160 | context: &mut Context, 161 | reply_tx: ResultSender>, 162 | register_group_id: &RegisterGroupId, 163 | request: query::FilterRecordsRequest, 164 | ) { 165 | let response = task::block_in_place(|| { 166 | let query::FilterRecordsRequest { limit, filter } = request; 167 | context 168 | .filter_records(register_group_id, limit, &filter) 169 | .map_err(|err| { 170 | log::warn!("Failed to query filtered records: {}", err); 171 | err 172 | }) 173 | }); 174 | send_reply(reply_tx, response); 175 | } 176 | -------------------------------------------------------------------------------- /plugins/csv-register-recorder/src/internal/message_loop.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | use tokio::task; 4 | 5 | use msr_plugin::{message_channel, send_reply, MessageLoop}; 6 | 7 | use crate::{ 8 | api::{ 9 | event::{LifecycleEvent, NotificationEvent}, 10 | Command, Config, Event, Message, Query, RegisterGroupId, State, 11 | }, 12 | EventPubSub, MessageSender, Result, 13 | }; 14 | 15 | use super::{ 16 | context::{self, Context}, 17 | invoke_context_from_message_loop, 18 | }; 19 | 20 | struct ContextEventCallback { 21 | event_pubsub: EventPubSub, 22 | } 23 | 24 | impl context::ContextEventCallback for ContextEventCallback { 25 | fn data_directory_created(&self, register_group_id: &RegisterGroupId, fs_path: &Path) { 26 | let event = Event::Notification(NotificationEvent::DataDirectoryCreated { 27 | register_group_id: register_group_id.clone(), 28 | fs_path: fs_path.to_owned(), 29 | }); 30 | self.event_pubsub.publish_event(event); 31 | } 32 | } 33 | 34 | #[allow(clippy::too_many_lines)] // TODO 35 | pub(crate) fn create_message_loop( 36 | data_dir: PathBuf, 37 | file_name_prefix: String, 38 | event_pubsub: EventPubSub, 39 | initial_config: Config, 40 | initial_state: State, 41 | ) -> Result<(MessageLoop, MessageSender)> { 42 | let (message_tx, mut message_rx) = message_channel(); 43 | let context_events = ContextEventCallback { 44 | event_pubsub: event_pubsub.clone(), 45 | }; 46 | let mut context = Context::try_new( 47 | data_dir, 48 | file_name_prefix, 49 | initial_config, 50 | initial_state, 51 | Box::new(context_events) as _, 52 | )?; 53 | let message_loop = async move { 54 | let mut exit_message_loop = false; 55 | log::info!("Starting message loop"); 56 | event_pubsub.publish_event(Event::Lifecycle(LifecycleEvent::Started)); 57 | while let Some(msg) = message_rx.recv().await { 58 | match msg { 59 | Message::Command(command) => { 60 | log::trace!("Received command {:?}", command); 61 | match command { 62 | Command::ReplaceConfig(reply_tx, new_config) => { 63 | invoke_context_from_message_loop::command_replace_config( 64 | &mut context, 65 | &event_pubsub, 66 | reply_tx, 67 | new_config, 68 | ); 69 | } 70 | Command::ReplaceRegisterGroupConfig( 71 | reply_tx, 72 | register_group_id, 73 | new_config, 74 | ) => { 75 | invoke_context_from_message_loop::command_replace_register_group_config( 76 | &mut context, 77 | &event_pubsub, 78 | reply_tx, 79 | register_group_id, 80 | new_config, 81 | ); 82 | } 83 | Command::SwitchState(reply_tx, new_state) => { 84 | invoke_context_from_message_loop::command_switch_state( 85 | &mut context, 86 | &event_pubsub, 87 | reply_tx, 88 | new_state, 89 | ); 90 | } 91 | Command::RecordObservedRegisterGroupValues( 92 | reply_tx, 93 | register_group_id, 94 | observed_register_values, 95 | ) => { 96 | invoke_context_from_message_loop::command_record_observed_register_group_values( 97 | &mut context, 98 | reply_tx, 99 | register_group_id, 100 | observed_register_values, 101 | ); 102 | } 103 | Command::Shutdown(reply_tx) => { 104 | invoke_context_from_message_loop::command_shutdown(reply_tx); 105 | exit_message_loop = true; 106 | } 107 | Command::SmokeTest(reply_tx) => { 108 | // TODO: Remove 109 | let response = task::block_in_place(|| context.smoke_test()); 110 | send_reply(reply_tx, response); 111 | } 112 | } 113 | } 114 | Message::Query(query) => { 115 | log::debug!("Received query {:?}", query); 116 | match query { 117 | Query::Config(reply_tx) => { 118 | invoke_context_from_message_loop::query_config(&context, reply_tx); 119 | } 120 | Query::RegisterGroupConfig(reply_tx, register_group_id) => { 121 | invoke_context_from_message_loop::query_register_group_config( 122 | &context, 123 | reply_tx, 124 | ®ister_group_id, 125 | ); 126 | } 127 | Query::Status(reply_tx, request) => { 128 | invoke_context_from_message_loop::query_status( 129 | &mut context, 130 | reply_tx, 131 | &request, 132 | ); 133 | } 134 | Query::RecentRecords(reply_tx, register_group_id, request) => { 135 | invoke_context_from_message_loop::query_recent_records( 136 | &mut context, 137 | reply_tx, 138 | ®ister_group_id, 139 | request, 140 | ); 141 | } 142 | Query::FilterRecords(reply_tx, register_group_id, request) => { 143 | invoke_context_from_message_loop::query_filter_records( 144 | &mut context, 145 | reply_tx, 146 | ®ister_group_id, 147 | request, 148 | ); 149 | } 150 | } 151 | } 152 | } 153 | if exit_message_loop { 154 | log::info!("Exiting message loop"); 155 | break; 156 | } 157 | } 158 | log::info!("Message loop terminated"); 159 | event_pubsub.publish_event(Event::Lifecycle(LifecycleEvent::Stopped)); 160 | }; 161 | Ok((Box::pin(message_loop), message_tx)) 162 | } 163 | -------------------------------------------------------------------------------- /plugins/csv-register-recorder/src/internal/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod context; 2 | pub(crate) mod message_loop; 3 | pub(crate) mod register; 4 | 5 | mod invoke_context_from_message_loop; 6 | -------------------------------------------------------------------------------- /plugins/csv-register-recorder/src/internal/register.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | // Re-exports 4 | pub use msr_core::{Value, ValueType as Type}; 5 | 6 | pub type ObservedRegisterValues = msr_core::register::recorder::ObservedRegisterValues; 7 | pub type Record = msr_core::register::recorder::Record; 8 | pub type StoredRecord = msr_core::register::recorder::StoredRecord; 9 | 10 | pub type GroupIdValue = String; 11 | 12 | #[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd)] 13 | pub struct GroupId(GroupIdValue); 14 | 15 | impl GroupId { 16 | #[must_use] 17 | pub const fn from_value(value: GroupIdValue) -> Self { 18 | Self(value) 19 | } 20 | 21 | #[must_use] 22 | pub fn into_value(self) -> GroupIdValue { 23 | let GroupId(value) = self; 24 | value 25 | } 26 | } 27 | 28 | impl From for GroupId { 29 | fn from(from: GroupIdValue) -> Self { 30 | Self::from_value(from) 31 | } 32 | } 33 | 34 | impl From for GroupIdValue { 35 | fn from(from: GroupId) -> Self { 36 | from.into_value() 37 | } 38 | } 39 | 40 | impl AsRef for GroupId { 41 | fn as_ref(&self) -> &str { 42 | &self.0 43 | } 44 | } 45 | 46 | impl fmt::Display for GroupId { 47 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 48 | f.write_str(self.as_ref()) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /plugins/csv-register-recorder/src/lib.rs: -------------------------------------------------------------------------------- 1 | // FIXME: Enable `deny(missing_docs)` before release 2 | //#![deny(missing_docs)] 3 | 4 | #![warn(rust_2018_idioms)] 5 | #![warn(rust_2021_compatibility)] 6 | #![warn(missing_debug_implementations)] 7 | #![warn(unreachable_pub)] 8 | #![warn(unsafe_code)] 9 | #![warn(rustdoc::broken_intra_doc_links)] 10 | #![warn(clippy::pedantic)] 11 | // Additional restrictions 12 | #![warn(clippy::clone_on_ref_ptr)] 13 | #![warn(clippy::self_named_module_files)] 14 | // Exceptions 15 | #![allow(clippy::default_trait_access)] 16 | #![allow(clippy::module_name_repetitions)] 17 | #![allow(clippy::missing_errors_doc)] // TODO 18 | #![allow(clippy::missing_panics_doc)] // TODO 19 | #![allow(clippy::unnecessary_wraps)] // TODO 20 | 21 | use std::{ 22 | io::Error as IoError, 23 | num::{NonZeroU32, NonZeroU64}, 24 | path::PathBuf, 25 | }; 26 | 27 | use thiserror::Error; 28 | 29 | use msr_core::{ 30 | register::recorder::Error as MsrRecordError, 31 | storage::{ 32 | Error as MsrStorageError, MemorySize, StorageConfig, StorageSegmentConfig, TimeInterval, 33 | }, 34 | }; 35 | 36 | use msr_plugin::EventPublisherIndex; 37 | 38 | pub mod api; 39 | use self::api::Config; 40 | 41 | mod internal; 42 | use self::internal::message_loop::create_message_loop; 43 | 44 | #[derive(Debug, Clone)] 45 | pub struct Environment { 46 | pub event_publisher_index: EventPublisherIndex, 47 | 48 | /// Directory for storing CSV data 49 | pub data_dir: PathBuf, 50 | 51 | pub custom_file_name_prefix: Option, 52 | } 53 | 54 | #[must_use] 55 | pub fn default_storage_config() -> StorageConfig { 56 | StorageConfig { 57 | retention_time: TimeInterval::Days(NonZeroU32::new(180).unwrap()), // 180 days 58 | segmentation: StorageSegmentConfig { 59 | time_interval: TimeInterval::Days(NonZeroU32::new(1).unwrap()), // daily 60 | size_limit: MemorySize::Bytes(NonZeroU64::new(1_048_576).unwrap()), // 1 MiB 61 | }, 62 | } 63 | } 64 | 65 | #[must_use] 66 | pub fn default_config() -> Config { 67 | Config { 68 | default_storage: default_storage_config(), 69 | register_groups: Default::default(), 70 | } 71 | } 72 | 73 | #[derive(Debug, Clone, PartialEq, Eq)] 74 | pub struct PluginSetup { 75 | pub initial_config: api::Config, 76 | pub initial_state: api::State, 77 | } 78 | 79 | impl Default for PluginSetup { 80 | fn default() -> Self { 81 | Self { 82 | initial_config: default_config(), 83 | initial_state: api::State::Inactive, 84 | } 85 | } 86 | } 87 | 88 | #[derive(Error, Debug)] 89 | pub enum Error { 90 | #[error("register group not configured")] 91 | RegisterGroupUnknown, 92 | 93 | #[error("invalid data format")] 94 | DataFormatInvalid, 95 | 96 | #[error(transparent)] 97 | Io(#[from] IoError), 98 | 99 | #[error(transparent)] 100 | MsrRecord(#[from] MsrRecordError), 101 | 102 | #[error(transparent)] 103 | MsrStorage(#[from] MsrStorageError), 104 | 105 | #[error(transparent)] 106 | Other(#[from] anyhow::Error), 107 | } 108 | 109 | pub type Result = std::result::Result; 110 | 111 | pub type PluginError = msr_plugin::PluginError; 112 | pub type PluginResult = msr_plugin::PluginResult; 113 | 114 | pub type MessageSender = msr_plugin::MessageSender; 115 | pub type MessageReceiver = msr_plugin::MessageReceiver; 116 | 117 | pub type ResultSender = msr_plugin::ResultSender; 118 | pub type ResultReceiver = msr_plugin::ResultReceiver; 119 | 120 | pub type PublishedEvent = msr_plugin::PublishedEvent; 121 | pub type EventReceiver = msr_plugin::EventReceiver; 122 | type EventPubSub = msr_plugin::EventPubSub; 123 | 124 | pub type Plugin = msr_plugin::PluginContainer; 125 | pub type PluginPorts = msr_plugin::PluginPorts; 126 | 127 | pub const DEFAULT_FILE_NAME_PREFIX: &str = "register_group_records_"; 128 | 129 | pub fn create_plugin( 130 | environment: Environment, 131 | plugin_setup: PluginSetup, 132 | event_channel_capacity: usize, 133 | ) -> Result { 134 | let Environment { 135 | event_publisher_index, 136 | data_dir, 137 | custom_file_name_prefix, 138 | } = environment; 139 | let PluginSetup { 140 | initial_config, 141 | initial_state, 142 | } = plugin_setup; 143 | let (event_pubsub, event_subscriber) = 144 | EventPubSub::new(event_publisher_index, event_channel_capacity); 145 | let file_name_prefix = 146 | custom_file_name_prefix.unwrap_or_else(|| DEFAULT_FILE_NAME_PREFIX.to_owned()); 147 | let (message_loop, message_tx) = create_message_loop( 148 | data_dir, 149 | file_name_prefix, 150 | event_pubsub, 151 | initial_config, 152 | initial_state, 153 | )?; 154 | Ok(Plugin { 155 | ports: PluginPorts { 156 | message_tx, 157 | event_subscriber, 158 | }, 159 | message_loop, 160 | }) 161 | } 162 | --------------------------------------------------------------------------------