From 44f4a794d3999e990a61313507d17b8de0ffb84e Mon Sep 17 00:00:00 2001 From: caoqianming Date: Tue, 3 Mar 2026 13:30:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=BD=AF=E4=BB=B6=E7=AC=AC=E4=B8=80?= =?UTF-8?q?=E4=B8=AA=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 28 + Cargo.lock | 3216 +++++++++++++++++ Cargo.toml | 43 + migrations/20260224065328_initial.sql | 58 + migrations/20260224065329_add_point_group.sql | 15 + src/config.rs | 40 + src/connection.rs | 1170 ++++++ src/db.rs | 15 + src/event.rs | 180 + src/handler.rs | 3 + src/handler/point.rs | 508 +++ src/handler/source.rs | 583 +++ src/handler/tag.rs | 102 + src/main.rs | 150 + src/middleware.rs | 37 + src/model.rs | 120 + src/service.rs | 300 ++ src/telemetry.rs | 154 + src/util.rs | 4 + src/util/datetime.rs | 24 + src/util/log.rs | 36 + src/util/response.rs | 91 + src/util/validator.rs | 34 + src/websocket.rs | 269 ++ 24 files changed, 7180 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 migrations/20260224065328_initial.sql create mode 100644 migrations/20260224065329_add_point_group.sql create mode 100644 src/config.rs create mode 100644 src/connection.rs create mode 100644 src/db.rs create mode 100644 src/event.rs create mode 100644 src/handler.rs create mode 100644 src/handler/point.rs create mode 100644 src/handler/source.rs create mode 100644 src/handler/tag.rs create mode 100644 src/main.rs create mode 100644 src/middleware.rs create mode 100644 src/model.rs create mode 100644 src/service.rs create mode 100644 src/telemetry.rs create mode 100644 src/util.rs create mode 100644 src/util/datetime.rs create mode 100644 src/util/log.rs create mode 100644 src/util/response.rs create mode 100644 src/util/validator.rs create mode 100644 src/websocket.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..00eecb3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Rust build output +/target + +# Environment and local secrets +.env +.env.* +!.env.example + +# Local runtime data/logs +/logs/ +/data/ + +# PKI private keys and generated cert artifacts +/pki/ +*.pem +*.key +*.pfx +*.p12 + +# IDE/editor +.vscode/ +.idea/ +*.swp +*.swo + +# OS files +.DS_Store +Thumbs.db diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..a77f47f --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3216 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arc-swap" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5" +dependencies = [ + "rustversion", +] + +[[package]] +name = "async-opcua" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b41aafb34371afea4ce25f026c5a46e5872d2aca6a3fdcf1fcc12d52636768" +dependencies = [ + "async-opcua-client", + "async-opcua-core", + "async-opcua-crypto", + "async-opcua-macros", + "async-opcua-types", + "chrono", +] + +[[package]] +name = "async-opcua-client" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e33d8045bf3d4187bdb5eb4f224aa7fecb804c63a79ac32ad38950b4f05ac159" +dependencies = [ + "arc-swap", + "async-opcua-core", + "async-opcua-crypto", + "async-opcua-nodes", + "async-opcua-types", + "async-trait", + "chrono", + "futures", + "hashbrown 0.15.5", + "parking_lot", + "rsa", + "serde", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "async-opcua-core" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a42c80b9cf8b9501a48961895b1f750a550414ce2fe54fe89599394d64277540" +dependencies = [ + "async-opcua-crypto", + "async-opcua-types", + "bytes", + "chrono", + "parking_lot", + "serde", + "serde_yaml", + "thiserror 1.0.69", + "tokio", + "tokio-util", + "tracing", + "url", +] + +[[package]] +name = "async-opcua-crypto" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "191907b348a30a2f02b910704a0a18a46de78ee32c71efceac6da459104b4d2b" +dependencies = [ + "aes", + "async-opcua-types", + "cbc", + "chrono", + "const-oid", + "gethostname", + "hmac", + "num-bigint-dig", + "rand 0.8.5", + "rsa", + "serde", + "sha1", + "sha2", + "tracing", + "x509-cert", +] + +[[package]] +name = "async-opcua-macros" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76e8cb06fbdab437ab32ae38fee132f3c5a4065664cdc2448087e06518103a1b" +dependencies = [ + "base64", + "convert_case", + "proc-macro2", + "quote", + "syn", + "uuid", +] + +[[package]] +name = "async-opcua-nodes" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9aa04366fbb50e5e5eab459604657ee0872f1b7f4da7bd0eb086160ccc7cd04" +dependencies = [ + "async-opcua-macros", + "async-opcua-types", + "bitflags", + "hashbrown 0.15.5", + "regex", + "thiserror 1.0.69", + "tracing", +] + +[[package]] +name = "async-opcua-types" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754239d6e4744a6fc46a31de56af8817728474d13ae7af37309f13d549e24630" +dependencies = [ + "async-opcua-macros", + "base64", + "bitflags", + "byteorder", + "chrono", + "hashbrown 0.15.5", + "percent-encoding-rfc3986", + "regex", + "thiserror 1.0.69", + "tracing", + "uuid", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "base64", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "der_derive", + "flagset", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gateway_rs" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-opcua", + "axum", + "chrono", + "dotenv", + "serde", + "serde_json", + "sqlx", + "time", + "tokio", + "tower-http", + "tracing", + "tracing-appender", + "tracing-subscriber", + "uuid", + "validator", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3655aa6818d65bc620d6911f05aa7b6aeb596291e1e9f79e52df85583d1e30" +dependencies = [ + "rustix", + "windows-targets 0.52.6", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.7.3", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "serde", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "percent-encoding-rfc3986" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3637c05577168127568a64e9dc5a6887da720efef07b3d9472d45f63ab191166" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "sha1", + "sha2", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.5", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.5", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.18", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "http", + "pin-project-lite", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-appender" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" +dependencies = [ + "crossbeam-channel", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "time", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.2", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +dependencies = [ + "getrandom 0.4.1", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "validator" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" +dependencies = [ + "darling", + "once_cell", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid", + "der", + "sha1", + "signature", + "spki", + "tls_codec", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..981f3ec --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "gateway_rs" +version = "0.1.0" +edition = "2021" + +[dependencies] +# Async runtime +tokio = { version = "1.49", features = ["full"] } + +# Web framework +axum = { version = "0.8", features = ["ws"] } +tower-http = { version = "0.6", features = ["cors"] } + +# Database +sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "chrono", "uuid"] } + +# Serialization +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# Time handling +chrono = "0.4" +time = "0.3" + +# UUID +uuid = { version = "1.21", features = ["serde", "v4"] } + +# OPC UA +async-opcua = { version = "0.18", features = ["client"] } + +# Logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "time"] } +tracing-appender = "0.2" + +# Environment variables +dotenv = "0.15" + +# Validation +validator = { version = "0.20", features = ["derive"] } + +# Error handling +anyhow = "1.0" diff --git a/migrations/20260224065328_initial.sql b/migrations/20260224065328_initial.sql new file mode 100644 index 0000000..0e6d1e2 --- /dev/null +++ b/migrations/20260224065328_initial.sql @@ -0,0 +1,58 @@ +-- Add migration script here + +-- 1️⃣ Source 表 +CREATE TABLE source ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + protocol TEXT NOT NULL, + endpoint TEXT NOT NULL, + security_policy TEXT, + security_mode TEXT, + username TEXT, + password TEXT, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + unique (endpoint) -- 唯一约束:endpoint 不重复 +); + +-- 2️⃣ Node 表 +CREATE TABLE node ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + source_id UUID NOT NULL, + external_id TEXT NOT NULL, -- 主查字段 + namespace_uri TEXT, -- 防止index变化 + namespace_index INTEGER, -- 仅作记录 + identifier_type TEXT, -- 仅作记录 + identifier TEXT, -- 仅作记录 + browse_name TEXT NOT NULL, + display_name TEXT, + node_class TEXT NOT NULL, + parent_id UUID, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + FOREIGN KEY (source_id) REFERENCES source(id), + FOREIGN KEY (parent_id) REFERENCES node(id), + UNIQUE(source_id, external_id) +); + +-- Node 常用索引 +CREATE INDEX idx_node_source_id ON node(source_id); +CREATE INDEX idx_node_parent_id ON node(parent_id); + +-- 3️⃣ Point 表 +CREATE TABLE point ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + node_id UUID NOT NULL, + name TEXT NOT NULL, + description TEXT, + unit TEXT, + scan_interval_s INTEGER NOT NULL DEFAULT 1 CHECK (scan_interval_s > 0), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + FOREIGN KEY (node_id) REFERENCES node(id), + UNIQUE (node_id) +); + +-- Point 常用索引 +CREATE INDEX idx_point_node_id ON point(node_id); diff --git a/migrations/20260224065329_add_point_group.sql b/migrations/20260224065329_add_point_group.sql new file mode 100644 index 0000000..0778ba4 --- /dev/null +++ b/migrations/20260224065329_add_point_group.sql @@ -0,0 +1,15 @@ +-- Tag 表 +CREATE TABLE tag ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + description TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE (name) +); + +-- 在 point 表中添加 tag_id 字段 +ALTER TABLE point ADD COLUMN tag_id UUID REFERENCES tag(id) ON DELETE SET NULL; + +-- 常用索引 +CREATE INDEX idx_point_tag_id ON point(tag_id); diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..0d400c2 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,40 @@ +use std::env; + +#[derive(Clone)] +pub struct AppConfig { + pub database_url: String, + pub server_host: String, + pub server_port: u16, + pub write_api_key: Option, +} + + +impl AppConfig { + pub fn from_env() -> Result { + let database_url = get_env("DATABASE_URL")?; + let server_host = env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string()); + let server_port = env::var("PORT") + .unwrap_or_else(|_| "60309".to_string()) + .parse::() + .map_err(|_| "PORT must be a number")?; + let write_api_key = env::var("WRITE_KEY").ok(); + + Ok(Self { + database_url, + server_host, + server_port, + write_api_key, + }) + } + + pub fn verify_write_key(&self, key: &str) -> bool { + self.write_api_key + .as_ref() + .map(|expected| expected == key) + .unwrap_or(false) + } +} + +fn get_env(key: &str) -> Result { + env::var(key).map_err(|_| format!("Missing environment variable: {}", key)) +} diff --git a/src/connection.rs b/src/connection.rs new file mode 100644 index 0000000..2e104cf --- /dev/null +++ b/src/connection.rs @@ -0,0 +1,1170 @@ +use chrono::{DateTime, Utc}; +use opcua::{ + client::{ClientBuilder, IdentityToken, Session}, + crypto::SecurityPolicy, + types::{ + AttributeId, MessageSecurityMode, MonitoredItemCreateRequest, MonitoringMode, + MonitoringParameters, NodeId, NumericRange, ReadValueId, TimestampsToReturn, Variant, + WriteValue, DataValue as OpcuaDataValue, + UserTokenPolicy, + }, +}; +use std::{ + collections::{HashMap, HashSet, VecDeque}, + str::FromStr, + sync::Arc, + time::Duration, +}; +use tokio::task::JoinHandle; +use tokio::sync::RwLock; +use uuid::Uuid; + +use crate::{ + model::{PointSubscriptionInfo, ScanMode}, + telemetry::PointMonitorInfo, +}; + +const DEFAULT_POINT_RING_BUFFER_LEN: usize = 1000; + +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +pub struct SetPointValueReqItem { + pub point_id: Uuid, + pub value: serde_json::Value, +} + +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +pub struct BatchSetPointValueReq { + pub items: Vec, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct SetPointValueResItem { + pub point_id: Uuid, + pub success: bool, + pub err_msg: Option, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct BatchSetPointValueRes { + pub success: bool, + pub err_msg: Option, + pub success_count: usize, + pub failed_count: usize, + pub results: Vec, +} + +#[derive(Debug, Clone)] +struct PointWriteTarget { + source_id: Uuid, + external_id: String, +} + +#[derive(Clone)] +pub struct ConnectionStatus { + pub session: Option>, + pub is_connected: bool, + pub last_error: Option, + pub last_time: DateTime, + pub subscription_id: Option, + pub next_client_handle: u32, + pub client_handle_map: HashMap, // client_handle -> point_id + pub monitored_item_map: HashMap, // point_id -> monitored_item_id +} + +#[derive(Clone)] +pub struct ConnectionManager { + status: Arc>>, + point_monitor_data: Arc>>, + point_history_data: Arc>>>, + point_write_target_cache: Arc>>, + poll_task_handles: Arc>>>, + poll_points_by_source: Arc>>>, + pool: Option, + event_manager: Option>, +} + + +impl ConnectionManager { + fn subscription_result(subscribed: usize, polled: usize) -> HashMap { + let mut result = HashMap::new(); + result.insert("subscribed".to_string(), subscribed); + result.insert("polled".to_string(), polled); + result.insert("total".to_string(), subscribed + polled); + result + } + + fn json_value_to_opcua_variant(value: &serde_json::Value) -> Result { + match value { + serde_json::Value::Null => Ok(Variant::Empty), + serde_json::Value::Bool(v) => Ok(Variant::from(*v)), + serde_json::Value::Number(n) => { + if let Some(v) = n.as_i64() { + Ok(Variant::from(v)) + } else if let Some(v) = n.as_u64() { + Ok(Variant::from(v)) + } else if let Some(v) = n.as_f64() { + Ok(Variant::from(v)) + } else { + Err("Unsupported numeric value".to_string()) + } + } + serde_json::Value::String(s) => Ok(Variant::from(s.clone())), + _ => Err("Only null/bool/number/string are supported for point write".to_string()), + } + } + + fn write_value_batch_result( + success: bool, + err_msg: Option, + results: Vec, + ) -> BatchSetPointValueRes { + let success_count = results.iter().filter(|r| r.success).count(); + let failed_count = results.len().saturating_sub(success_count); + BatchSetPointValueRes { + success, + err_msg, + success_count, + failed_count, + results, + } + } + + pub fn new_with_pool(pool: sqlx::PgPool) -> Self { + Self { + status: Arc::new(RwLock::new(HashMap::new())), + pool: Some(pool), + point_monitor_data: Arc::new(RwLock::new(HashMap::new())), + point_history_data: Arc::new(RwLock::new(HashMap::new())), + point_write_target_cache: Arc::new(RwLock::new(HashMap::new())), + poll_task_handles: Arc::new(RwLock::new(HashMap::new())), + poll_points_by_source: Arc::new(RwLock::new(HashMap::new())), + event_manager: None, + } + } + + pub fn set_event_manager(&mut self, event_manager: std::sync::Arc) { + self.event_manager = Some(event_manager); + } + + pub async fn remove_point_write_target_cache_by_point_ids( + &self, + point_ids: &[Uuid], + ) -> usize { + if point_ids.is_empty() { + return 0; + } + + let mut removed = 0usize; + let mut cache = self.point_write_target_cache.write().await; + for point_id in point_ids { + if cache.remove(point_id).is_some() { + removed += 1; + } + } + removed + } + + pub async fn update_point_monitor_data( + &self, + sample: PointMonitorInfo, + ) -> Result<(), String> { + let point_id = sample.point_id; + + let mut data = self.point_monitor_data.write().await; + data.insert(point_id, sample.clone()); + + let mut history_data = self.point_history_data.write().await; + let ring = history_data + .entry(point_id) + .or_insert_with(|| VecDeque::with_capacity(DEFAULT_POINT_RING_BUFFER_LEN)); + ring.push_back(sample); + if ring.len() > DEFAULT_POINT_RING_BUFFER_LEN { + let _ = ring.pop_front(); + } + + Ok(()) + } + + pub async fn get_status_read_guard(&self) -> tokio::sync::RwLockReadGuard<'_, HashMap> { + self.status.read().await + } + + pub async fn get_point_monitor_data_read_guard( + &self, + ) -> tokio::sync::RwLockReadGuard<'_, HashMap> { + self.point_monitor_data.read().await + } + + async fn start_polling_for_point( + &self, + source_id: Uuid, + point: PointSubscriptionInfo, + session: Arc, + ) -> Result<(), String> { + let interval_s = point.scan_interval_s; + if interval_s <= 0 { + return Err(format!( + "Point {} has invalid scan_interval_s {}", + point.point_id, point.scan_interval_s + )); + } + + let node_id = NodeId::from_str(&point.external_id) + .map_err(|e| format!("Invalid node id {}: {}", point.external_id, e))?; + + let event_manager = self + .event_manager + .clone() + .ok_or_else(|| "Event manager is not initialized".to_string())?; + + { + let poll_tasks = self.poll_task_handles.read().await; + if poll_tasks.contains_key(&point.point_id) { + return Ok(()); + } + } + + let point_id = point.point_id; + let external_id = point.external_id.clone(); + let interval_sec_u64 = u64::try_from(interval_s) + .map_err(|_| format!("Invalid scan_interval_s {} for point {}", interval_s, point_id))?; + + let handle = tokio::spawn(async move { + let mut ticker = tokio::time::interval(Duration::from_secs(interval_sec_u64)); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + ticker.tick().await; + + let read_request = ReadValueId { + node_id: node_id.clone(), + attribute_id: AttributeId::Value as u32, + index_range: NumericRange::None, + data_encoding: Default::default(), + }; + + match session + .read(&[read_request], TimestampsToReturn::Both, 0f64) + .await + { + Ok(result) if !result.is_empty() => { + let dv = &result[0]; + let val = dv.value.clone(); + let unified_value = + val.as_ref().map(crate::telemetry::opcua_variant_to_data); + let unified_value_type = + val.as_ref().map(crate::telemetry::opcua_variant_type); + let unified_value_text = val.as_ref().map(|v| v.to_string()); + let quality = dv + .status + .as_ref() + .map(crate::telemetry::PointQuality::from_status_code) + .unwrap_or(crate::telemetry::PointQuality::Unknown); + + let _ = event_manager.send(crate::event::ReloadEvent::PointValueChange( + crate::telemetry::PointValueChangeEvent { + source_id, + point_id: Some(point_id), + client_handle: 0, + value: unified_value, + value_type: unified_value_type, + value_text: unified_value_text, + quality, + protocol: "opcua".to_string(), + timestamp: Some(Utc::now()), + scan_mode: ScanMode::Poll, + }, + )); + } + Ok(_) => { + tracing::warn!( + "Poll read returned empty result for point {} node {}", + point_id, + external_id + ); + } + Err(e) => { + tracing::warn!( + "Poll read failed for point {} node {}: {:?}", + point_id, + external_id, + e + ); + } + } + } + }); + + { + let mut poll_tasks = self.poll_task_handles.write().await; + poll_tasks.insert(point_id, handle); + } + { + let mut points_by_source = self.poll_points_by_source.write().await; + points_by_source + .entry(source_id) + .or_insert_with(HashSet::new) + .insert(point_id); + } + + Ok(()) + } + + async fn stop_polling_for_point(&self, point_id: Uuid) { + if let Some(handle) = self.poll_task_handles.write().await.remove(&point_id) { + handle.abort(); + } + + let mut points_by_source = self.poll_points_by_source.write().await; + let source_ids: Vec = points_by_source + .iter() + .filter(|(_, point_set)| point_set.contains(&point_id)) + .map(|(source_id, _)| *source_id) + .collect(); + + for source_id in source_ids { + if let Some(point_set) = points_by_source.get_mut(&source_id) { + point_set.remove(&point_id); + if point_set.is_empty() { + points_by_source.remove(&source_id); + } + } + } + } + + async fn stop_polling_for_source(&self, source_id: Uuid) { + let point_ids = { + let mut points_by_source = self.poll_points_by_source.write().await; + points_by_source + .remove(&source_id) + .map(|set| set.into_iter().collect::>()) + .unwrap_or_default() + }; + + if point_ids.is_empty() { + return; + } + + let mut poll_tasks = self.poll_task_handles.write().await; + for point_id in point_ids { + if let Some(handle) = poll_tasks.remove(&point_id) { + handle.abort(); + } + } + } + + async fn start_polling_for_points( + &self, + source_id: Uuid, + points: &[PointSubscriptionInfo], + session: Arc, + ) -> usize { + let mut started = 0usize; + for point in points.iter().cloned() { + match self + .start_polling_for_point(source_id, point.clone(), session.clone()) + .await + { + Ok(()) => { + started += 1; + tracing::info!( + "Point {} switched to poll mode with scan_interval_s {}", + point.point_id, + point.scan_interval_s + ); + } + Err(e) => { + tracing::warn!( + "Point {} cannot switch to poll mode: {}", + point.point_id, + e + ); + } + } + } + started + } + + pub async fn connect_from_source( + &self, + pool: &sqlx::PgPool, + source_id: Uuid, + ) -> Result<(), String> { + let source = match crate::service::get_enabled_source(pool, source_id).await { + Ok(Some(s)) => s, + Ok(None) => { + tracing::info!( + "Source {} is not enabled or does not exist, skipping connection", + source_id + ); + return Ok(()); + } + Err(e) => { + let error = format!("Failed to fetch source: {}", e); + tracing::error!("{}", error); + return Err(error); + } + }; + + self.connect( + source.id, + &source.endpoint, + source.security_policy.as_deref(), + source.security_mode.as_deref(), + source.username.as_deref(), + source.password.as_deref(), + ) + .await + } + + pub async fn connect( + &self, + source_id: Uuid, + endpoint: &str, + security_policy: Option<&str>, + security_mode: Option<&str>, + username: Option<&str>, + password: Option<&str>, + ) -> Result<(), String> { + let mut client = match ClientBuilder::new() + .application_name("plc_gateway") + .application_uri("urn:plc_gateway:opcua-client") + .trust_server_certs(true) + .create_sample_keypair(true) + .session_retry_limit(3) + .client() + { + Ok(client) => client, + Err(e) => { + let error = format!("Failed to create client: {:?}", e); + self.fail_connect(source_id, &error).await; + return Err(error); + } + }; + + let security_policy = if security_policy.is_none() { + SecurityPolicy::None + } else { + security_policy + .and_then(|s| SecurityPolicy::from_str(s).ok()) + .unwrap_or(SecurityPolicy::None) + }; + + let security_mode = match security_mode { + Some("Sign") => MessageSecurityMode::Sign, + Some("SignAndEncrypt") => MessageSecurityMode::SignAndEncrypt, + _ => MessageSecurityMode::None, + }; + + let identity_token = match (username, password) { + (Some(user), Some(pass)) => IdentityToken::new_user_name(user, pass), + _ => IdentityToken::Anonymous, + }; + + let (session, event_loop) = match client + .connect_to_matching_endpoint( + ( + endpoint, + security_policy.to_str(), + security_mode, + UserTokenPolicy::anonymous(), + ), + identity_token, + ) + .await + { + Ok(res) => res, + Err(e) => { + let error = format!("Failed to connect: {:?}", e); + self.fail_connect(source_id, &error).await; + return Err(error); + } + }; + + let _ = event_loop.spawn(); + session.wait_for_connection().await; + + let mut status = self.status.write().await; + status.insert( + source_id, + ConnectionStatus { + session: Some(session), + is_connected: true, + last_error: None, + last_time: Utc::now(), + subscription_id: None, + next_client_handle: 1000, + client_handle_map: HashMap::new(), + monitored_item_map: HashMap::new(), + }, + ); + + tracing::info!("Successfully connected to source {}", source_id); + Ok(()) + } + + pub async fn fail_connect(&self, source_id: Uuid, error: &str) { + let mut status = self.status.write().await; + status.insert( + source_id, + ConnectionStatus { + session: None, + is_connected: false, + last_error: Some(error.to_string()), + last_time: Utc::now(), + subscription_id: None, + client_handle_map: HashMap::new(), + monitored_item_map: HashMap::new(), + next_client_handle: 1000, + }, + ); + } + pub async fn disconnect(&self, source_id: Uuid) -> Result<(), String> { + self.stop_polling_for_source(source_id).await; + + let conn_status = self.status.write().await.remove(&source_id); + if let Some(conn_status) = conn_status { + if let Some(session) = conn_status.session { + session.disconnect().await.map_err(|e| { + let error = format!("Failed to disconnect: {}", e); + tracing::error!("{}", error); + error + })?; + } + } + Ok(()) + } + + pub async fn disconnect_all(&self) { + let source_ids: Vec = self.status.read().await.keys().copied().collect(); + + for source_id in source_ids { + self.stop_polling_for_source(source_id).await; + + let conn_status = self.status.write().await.remove(&source_id); + if let Some(conn_status) = conn_status { + if let Some(session) = conn_status.session { + if let Err(e) = session.disconnect().await { + tracing::error!("Failed to disconnect from source {}: {}", source_id, e); + } + } + } + } + } + + pub async fn get_session(&self, source_id: Uuid) -> Option> { + // comment fixed + { + let status = self.status.read().await; + if let Some(conn_status) = status.get(&source_id) { + if conn_status.is_connected { + return conn_status.session.clone(); + } + } + } + + // comment fixed + if let Some(pool) = &self.pool { + if let Ok(()) = self.connect_from_source(pool, source_id).await { + // comment fixed + let status = self.status.read().await; + status.get(&source_id).and_then(|s| s.session.clone()) + } else { + None + } + } else { + None + } + } + + pub async fn get_status(&self, source_id: Uuid) -> Option { + let status = self.status.read().await; + status.get(&source_id).cloned() + } + + pub async fn get_all_status(&self) -> Vec<(Uuid, ConnectionStatus)> { + let status = self.status.read().await; + status.iter().map(|(source_id, conn_status)| (*source_id, conn_status.clone())).collect() + } + + pub async fn write_point_values_batch( + &self, + payload: BatchSetPointValueReq, + ) -> Result { + if payload.items.is_empty() { + return Ok(Self::write_value_batch_result( + false, + Some("items cannot be empty".to_string()), + vec![], + )); + } + + let target_map = self.point_write_target_cache.read().await; + + #[derive(Clone)] + struct PendingWrite { + point_id: Uuid, + source_id: Uuid, + external_id: String, + variant: Variant, + } + + let mut results = Vec::new(); + let mut pending_by_source: HashMap> = HashMap::new(); + for item in payload.items { + let Some(target) = target_map.get(&item.point_id) else { + results.push(SetPointValueResItem { + point_id: item.point_id, + success: false, + err_msg: Some("Point write target not found in cache".to_string()), + }); + continue; + }; + + let variant = match Self::json_value_to_opcua_variant(&item.value) { + Ok(v) => v, + Err(e) => { + results.push(SetPointValueResItem { + point_id: item.point_id, + success: false, + err_msg: Some(e), + }); + continue; + } + }; + + pending_by_source + .entry(target.source_id) + .or_default() + .push(PendingWrite { + point_id: item.point_id, + source_id: target.source_id, + external_id: target.external_id.clone(), + variant, + }); + } + + if !results.is_empty() { + return Ok(Self::write_value_batch_result( + false, + Some("Batch write precheck failed, no write executed".to_string()), + results, + )); + } + + // Validate all target sources are already connected before executing writes. + for source_id in pending_by_source.keys() { + let source_connected = self + .get_status(*source_id) + .await + .map(|s| s.is_connected && s.session.is_some()) + .unwrap_or(false); + if !source_connected { + return Ok(Self::write_value_batch_result( + false, + Some(format!("Source {} is not connected", source_id)), + vec![], + )); + } + } + + let mut success_events: Vec<(Uuid, Uuid, Variant)> = Vec::new(); + + for (source_id, writes) in pending_by_source { + let Some(session) = self.get_session(source_id).await else { + return Ok(Self::write_value_batch_result( + false, + Some(format!("Source {} is not connected", source_id)), + vec![], + )); + }; + + let mut write_values = Vec::new(); + let mut write_items = Vec::new(); + for write in writes { + let node_id = match NodeId::from_str(&write.external_id) { + Ok(v) => v, + Err(e) => { + results.push(SetPointValueResItem { + point_id: write.point_id, + success: false, + err_msg: Some(format!("Invalid node id {}: {}", write.external_id, e)), + }); + continue; + } + }; + + write_values.push(WriteValue { + node_id, + attribute_id: AttributeId::Value as u32, + index_range: NumericRange::None, + value: OpcuaDataValue::value_only(write.variant.clone()), + }); + write_items.push(write); + } + + if write_values.is_empty() { + continue; + } + + let status_codes = match session.write(&write_values).await { + Ok(v) => v, + Err(e) => { + let write_results: Vec = write_items + .into_iter() + .map(|write| SetPointValueResItem { + point_id: write.point_id, + success: false, + err_msg: Some(format!("Write failed: {:?}", e)), + }) + .collect(); + return Ok(Self::write_value_batch_result( + false, + Some("Batch write failed, no local updates emitted".to_string()), + write_results, + )); + } + }; + + for (idx, write) in write_items.iter().enumerate() { + let status_opt = status_codes.get(idx); + let status_ok = status_opt.map(|s| s.is_good()).unwrap_or(false); + + results.push(SetPointValueResItem { + point_id: write.point_id, + success: status_ok, + err_msg: if status_ok { + None + } else { + Some(match status_opt { + Some(s) => format!("Write rejected: {:?}", s), + None => "Write result missing from server response".to_string(), + }) + }, + }); + + if !status_ok { + return Ok(Self::write_value_batch_result( + false, + Some("Batch write failed, no local updates emitted".to_string()), + results, + )); + } + + success_events.push((write.source_id, write.point_id, write.variant.clone())); + } + } + + // Emit local updates only when the full batch succeeds. + if let Some(event_manager) = &self.event_manager { + for (source_id, point_id, variant) in success_events { + if let Err(e) = event_manager.send(crate::event::ReloadEvent::PointValueChange( + crate::telemetry::PointValueChangeEvent { + source_id, + point_id: Some(point_id), + client_handle: 0, + value: Some(crate::telemetry::opcua_variant_to_data(&variant)), + value_type: Some(crate::telemetry::opcua_variant_type(&variant)), + value_text: Some(variant.to_string()), + quality: crate::telemetry::PointQuality::Good, + protocol: "opcua".to_string(), + timestamp: Some(Utc::now()), + scan_mode: crate::model::ScanMode::Poll, + }, + )) { + tracing::warn!( + "Batch write succeeded but failed to dispatch point update for point {}: {}", + point_id, + e + ); + } + } + } + + Ok(Self::write_value_batch_result(true, None, results)) + } + + pub async fn subscribe_points_from_source( + &self, + source_id: Uuid, + point_ids: Option>, + pool: &sqlx::PgPool, + ) -> Result, String> { + let point_ids = point_ids.unwrap_or_default(); + + let mut points = crate::service::get_points_with_ids(pool, source_id, &point_ids) + .await + .map_err(|e| format!("Failed to get points for source {}: {}", source_id, e))?; + + { + let mut cache = self.point_write_target_cache.write().await; + for p in &points { + cache.insert( + p.point_id, + PointWriteTarget { + source_id, + external_id: p.external_id.clone(), + }, + ); + } + } + + if points.is_empty() { + tracing::info!("No valid points found to subscribe for source {}", source_id); + return Ok(Self::subscription_result(0, 0)); + } + + tracing::info!( + "Processing subscription for source {} with {} points", + source_id, + points.len() + ); + + let session = self + .get_session(source_id) + .await + .ok_or_else(|| format!("Failed to get session for source {}", source_id))?; + + let subscription_id = { + let status = self.status.read().await; + if let Some(conn_status) = status.get(&source_id) { + let subscribed_point_ids: std::collections::HashSet = + conn_status.client_handle_map.values().copied().collect(); + + let original_count = points.len(); + points.retain(|p| !subscribed_point_ids.contains(&p.point_id)); + let filtered_count = points.len(); + + if filtered_count < original_count { + tracing::info!( + "Filtered out {} already subscribed points for source {}", + original_count - filtered_count, + source_id + ); + } + + if points.is_empty() { + tracing::info!("All points are already subscribed for source {}", source_id); + return Ok(Self::subscription_result(0, 0)); + } + + conn_status.subscription_id + } else { + None + } + }; + + let subscription_id = match subscription_id { + Some(id) => Some(id), + None => { + let manager = self.clone(); + let current_source_id = source_id; + + match session + .create_subscription( + Duration::from_secs(1), + 10, + 30, + 0, + 0, + true, + opcua::client::DataChangeCallback::new(move |dv, item| { + let client_handle = item.client_handle(); + let val = dv.value; + let timex = Some(Utc::now()); + let unified_value = + val.as_ref().map(crate::telemetry::opcua_variant_to_data); + let unified_value_type = + val.as_ref().map(crate::telemetry::opcua_variant_type); + let unified_value_text = val.as_ref().map(|v| v.to_string()); + let quality = dv.status + .as_ref() + .map(crate::telemetry::PointQuality::from_status_code) + .unwrap_or(crate::telemetry::PointQuality::Unknown); + + if let Some(event_manager) = &manager.event_manager { + let _ = event_manager.send(crate::event::ReloadEvent::PointValueChange( + crate::telemetry::PointValueChangeEvent { + source_id: current_source_id, + point_id: None, + client_handle, + value: unified_value, + value_type: unified_value_type, + value_text: unified_value_text, + quality, + protocol: "opcua".to_string(), + timestamp: timex, + scan_mode: ScanMode::Subscribe, + })); + } + }), + ) + .await + { + Ok(id) => { + if let Some(conn_status) = manager.status.write().await.get_mut(&source_id) { + conn_status.subscription_id = Some(id); + } + + tracing::info!("Created subscription {} for source {}", id, source_id); + Some(id) + } + Err(e) => { + tracing::warn!( + "Failed to create subscription for source {}, falling back to poll mode: {:?}", + source_id, + e + ); + None + } + } + } + }; + + if subscription_id.is_none() { + let polled_count = self + .start_polling_for_points(source_id, &points, session.clone()) + .await; + return Ok(Self::subscription_result(0, polled_count)); + } + let subscription_id = subscription_id.unwrap_or_default(); + + let mut client_handle_seed: u32 = self + .status + .read() + .await + .get(&source_id) + .map_or(1000, |s| s.next_client_handle); + let mut items_to_create: Vec = Vec::new(); + let mut item_points: Vec = Vec::new(); + + for p in points.iter().cloned() { + let node_id = NodeId::from_str(&p.external_id) + .map_err(|e| format!("Invalid node id {}: {}", p.external_id, e))?; + + let client_handle = client_handle_seed; + client_handle_seed += 1; + + if let Some(s) = self.status.write().await.get_mut(&source_id) { + s.client_handle_map.insert(client_handle, p.point_id); + } + + let request = MonitoredItemCreateRequest { + item_to_monitor: ReadValueId { + node_id, + attribute_id: AttributeId::Value as u32, + index_range: Default::default(), + data_encoding: Default::default(), + }, + monitoring_mode: MonitoringMode::Reporting, + requested_parameters: MonitoringParameters { + client_handle, + sampling_interval: 0.0, + filter: Default::default(), + queue_size: 10, + discard_oldest: true, + }, + }; + + items_to_create.push(request); + item_points.push(p); + } + + let results = match session + .create_monitored_items(subscription_id, TimestampsToReturn::Both, items_to_create) + .await + { + Ok(results) => results, + Err(e) => { + tracing::warn!( + "Failed to create monitored items for source {}, falling back to poll mode: {:?}", + source_id, + e + ); + + let item_point_ids: HashSet = item_points.iter().map(|p| p.point_id).collect(); + if let Some(conn_status) = self.status.write().await.get_mut(&source_id) { + conn_status + .client_handle_map + .retain(|_, point_id| !item_point_ids.contains(point_id)); + conn_status.next_client_handle = client_handle_seed; + } + + let polled_count = self + .start_polling_for_points(source_id, &item_points, session.clone()) + .await; + return Ok(Self::subscription_result(0, polled_count)); + } + }; + + let mut successfully_subscribed_points = Vec::new(); + let mut successfully_subscribed_set: HashSet = HashSet::new(); + let mut failed_points: Vec = Vec::new(); + for (i, monitored_item_result) in results.iter().enumerate() { + if i >= item_points.len() { + break; + } + let point = &item_points[i]; + + if monitored_item_result.result.status_code.is_good() { + successfully_subscribed_points.push(point.point_id); + successfully_subscribed_set.insert(point.point_id); + if let Some(conn_status) = self.status.write().await.get_mut(&source_id) { + conn_status + .monitored_item_map + .insert(point.point_id, monitored_item_result.result.monitored_item_id); + } + self.stop_polling_for_point(point.point_id).await; + } else { + tracing::error!( + "Failed to create monitored item for point {}: {:?}", + point.point_id, + monitored_item_result.result.status_code + ); + failed_points.push(point.clone()); + } + } + + if results.len() < item_points.len() { + for point in item_points.iter().skip(results.len()) { + tracing::warn!( + "No monitored item result returned for point {}, fallback to poll", + point.point_id + ); + failed_points.push(point.clone()); + } + } + + failed_points.retain(|p| !successfully_subscribed_set.contains(&p.point_id)); + + if let Some(conn_status) = self.status.write().await.get_mut(&source_id) { + let failed_point_ids: HashSet = failed_points.iter().map(|p| p.point_id).collect(); + conn_status + .client_handle_map + .retain(|_, point_id| !failed_point_ids.contains(point_id)); + conn_status.next_client_handle = client_handle_seed; + } + + let polled_count = self + .start_polling_for_points(source_id, &failed_points, session.clone()) + .await; + + Ok(Self::subscription_result( + successfully_subscribed_points.len(), + polled_count, + )) + } + + /// + pub async fn unsubscribe_points_from_source( + &self, + source_id: Uuid, + point_ids: Vec, + ) -> Result { + if point_ids.is_empty() { + return Ok(0); + } + + let target_ids: std::collections::HashSet = point_ids.into_iter().collect(); + let (session, subscription_id, point_item_pairs) = { + let status = self.status.read().await; + let Some(conn_status) = status.get(&source_id) else { + return Ok(0); + }; + let Some(session) = conn_status.session.clone() else { + return Ok(0); + }; + let Some(subscription_id) = conn_status.subscription_id else { + return Ok(0); + }; + + let items: Vec<(Uuid, u32)> = conn_status + .monitored_item_map + .iter() + .filter(|(point_id, _)| target_ids.contains(point_id)) + .map(|(point_id, monitored_item_id)| (*point_id, *monitored_item_id)) + .collect(); + + (session, subscription_id, items) + }; + + if point_item_pairs.is_empty() { + return Ok(0); + } + + let monitored_item_ids: Vec = + point_item_pairs.iter().map(|(_, item_id)| *item_id).collect(); + let status_codes = session + .delete_monitored_items(subscription_id, &monitored_item_ids) + .await + .map_err(|e| { + format!( + "Failed to unsubscribe monitored items for source {}: {:?}", + source_id, e + ) + })?; + + let mut removed_point_ids: Vec = Vec::new(); + for (idx, status_code) in status_codes.iter().enumerate() { + if idx >= point_item_pairs.len() { + break; + } + if status_code.is_good() { + removed_point_ids.push(point_item_pairs[idx].0); + } else { + tracing::warn!( + "Failed to unsubscribe monitored item {} for source {}: {:?}", + point_item_pairs[idx].1, + source_id, + status_code + ); + } + } + + if removed_point_ids.is_empty() { + return Ok(0); + } + + let removed_set: std::collections::HashSet = + removed_point_ids.iter().copied().collect(); + + if let Some(conn_status) = self.status.write().await.get_mut(&source_id) { + for point_id in &removed_point_ids { + conn_status.monitored_item_map.remove(point_id); + } + conn_status + .client_handle_map + .retain(|_, point_id| !removed_set.contains(point_id)); + } + + for point_id in &removed_point_ids { + self.stop_polling_for_point(*point_id).await; + } + let _ = self + .remove_point_write_target_cache_by_point_ids(&removed_point_ids) + .await; + + { + let mut monitor_data = self.point_monitor_data.write().await; + for point_id in &removed_point_ids { + monitor_data.remove(point_id); + } + } + { + let mut history_data = self.point_history_data.write().await; + for point_id in &removed_point_ids { + history_data.remove(point_id); + } + } + tracing::info!( + "Unsubscribed {} points from source {}", + removed_point_ids.len(), + source_id + ); + Ok(removed_point_ids.len()) + } +} + + + + diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000..2a1a821 --- /dev/null +++ b/src/db.rs @@ -0,0 +1,15 @@ +use sqlx::PgPool; +use sqlx::postgres::PgPoolOptions; +use tracing::info; + +pub async fn init_database(database_url: &str) -> Result { + let pool = PgPoolOptions::new() + .max_connections(10) + .connect(database_url) + .await?; + + // MIGRATOR.run(&pool).await?; + + info!("数据库已连接,如有迁移请手动执行"); + Ok(pool) +} diff --git a/src/event.rs b/src/event.rs new file mode 100644 index 0000000..88cc159 --- /dev/null +++ b/src/event.rs @@ -0,0 +1,180 @@ +use tokio::sync::mpsc; +use uuid::Uuid; + +#[derive(Debug, Clone)] +pub enum ReloadEvent { + SourceCreate { + source_id: Uuid, + }, + SourceUpdate { + source_id: Uuid, + }, + SourceDelete { + source_id: Uuid, + }, + PointCreate { + source_id: Uuid, + point_id: Uuid, + }, + PointCreateBatch { + source_id: Uuid, + point_ids: Vec, + }, + PointDeleteBatch { + source_id: Uuid, + point_ids: Vec, + }, + PointValueChange(crate::telemetry::PointValueChangeEvent), +} + +pub struct EventManager { + sender: mpsc::UnboundedSender, +} + +impl EventManager { + pub fn new( + pool: sqlx::PgPool, + connection_manager: std::sync::Arc, + ws_manager: Option>, + ) -> Self { + let (sender, mut receiver) = mpsc::unbounded_channel::(); + let ws_manager_clone = ws_manager.clone(); + + tokio::spawn(async move { + while let Some(event) = receiver.recv().await { + match event { + ReloadEvent::SourceCreate { source_id } => { + tracing::info!("Processing SourceCreate event for {}", source_id); + if let Err(e) = connection_manager.connect_from_source(&pool, source_id).await { + tracing::error!("Failed to connect to source {}: {}", source_id, e); + } + } + ReloadEvent::SourceUpdate { source_id } => { + tracing::info!("SourceUpdate event for {}: not implemented yet", source_id); + } + ReloadEvent::SourceDelete { source_id } => { + tracing::info!("Processing SourceDelete event for {}", source_id); + if let Err(e) = connection_manager.disconnect(source_id).await { + tracing::error!("Failed to disconnect from source {}: {}", source_id, e); + } + } + ReloadEvent::PointCreate { source_id, point_id } => { + match connection_manager + .subscribe_points_from_source(source_id, Some(vec![point_id]), &pool) + .await + { + Ok(stats) => { + let subscribed = *stats.get("subscribed").unwrap_or(&0); + let polled = *stats.get("polled").unwrap_or(&0); + let total = *stats.get("total").unwrap_or(&0); + tracing::info!( + "PointCreate subscribe finished for source {} point {}: subscribed={}, polled={}, total={}", + source_id, + point_id, + subscribed, + polled, + total + ); + } + Err(e) => { + tracing::error!("Failed to subscribe to point {}: {}", point_id, e); + } + } + } + ReloadEvent::PointCreateBatch { source_id, point_ids } => { + let requested_count = point_ids.len(); + match connection_manager + .subscribe_points_from_source(source_id, Some(point_ids), &pool) + .await + { + Ok(stats) => { + let subscribed = *stats.get("subscribed").unwrap_or(&0); + let polled = *stats.get("polled").unwrap_or(&0); + let total = *stats.get("total").unwrap_or(&0); + tracing::info!( + "PointCreateBatch subscribe finished for source {}: requested={}, subscribed={}, polled={}, total={}", + source_id, + requested_count, + subscribed, + polled, + total + ); + } + Err(e) => { + tracing::error!("Failed to subscribe to points: {}", e); + } + } + } + ReloadEvent::PointDeleteBatch { source_id, point_ids } => { + tracing::info!( + "Processing PointDeleteBatch event for source {} with {} points", + source_id, + point_ids.len() + ); + if let Err(e) = connection_manager + .unsubscribe_points_from_source(source_id, point_ids) + .await + { + tracing::error!("Failed to unsubscribe points: {}", e); + } + } + ReloadEvent::PointValueChange(payload) => { + let source_id = payload.source_id; + let client_handle = payload.client_handle; + let point_id = if let Some(point_id) = payload.point_id { + Some(point_id) + } else { + let status = connection_manager.get_status_read_guard().await; + status + .get(&source_id) + .and_then(|s| s.client_handle_map.get(&client_handle).copied()) + }; + + if let Some(point_id) = point_id { + let monitor = crate::telemetry::PointMonitorInfo { + protocol: payload.protocol.clone(), + source_id, + point_id, + client_handle, + scan_mode: payload.scan_mode.clone(), + timestamp: payload.timestamp, + quality: payload.quality.clone(), + value: payload.value.clone(), + value_type: payload.value_type.clone(), + value_text: payload.value_text.clone(), + }; + + if let Err(e) = connection_manager.update_point_monitor_data(monitor.clone()).await { + tracing::error!("Failed to update point monitor data for point {}: {}", point_id, e); + } + + if let Some(ws_manager) = &ws_manager_clone { + let ws_message = crate::websocket::WsMessage::PointValueChange( + crate::telemetry::WsPointMonitorInfo::from(&monitor), + ); + + if let Err(e) = ws_manager.send_to_public(ws_message.clone()).await { + tracing::error!("Failed to send WebSocket message to public room: {}", e); + } + + if let Err(e) = ws_manager.send_to_client(point_id, ws_message).await { + tracing::error!("Failed to send WebSocket message to client room {}: {}", point_id, e); + } + } + } else { + tracing::warn!("Point not found for source {} client_handle {}", source_id, client_handle); + } + } + } + } + }); + + Self { sender } + } + + pub fn send(&self, event: ReloadEvent) -> Result<(), String> { + self.sender + .send(event) + .map_err(|e| format!("Failed to send event: {}", e)) + } +} diff --git a/src/handler.rs b/src/handler.rs new file mode 100644 index 0000000..0030a85 --- /dev/null +++ b/src/handler.rs @@ -0,0 +1,3 @@ +pub mod source; +pub mod point; +pub mod tag; \ No newline at end of file diff --git a/src/handler/point.rs b/src/handler/point.rs new file mode 100644 index 0000000..b90a54d --- /dev/null +++ b/src/handler/point.rs @@ -0,0 +1,508 @@ +锘縰se axum::{Json, extract::{Path, Query, State}, http::HeaderMap, response::IntoResponse}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; +use validator::Validate; + +use crate::util::response::ApiErr; + +use crate::{ + AppState, + model::{Node, Point}, +}; + +/// List all points. +#[derive(Deserialize)] +pub struct GetPointListQuery { + pub source_id: Option, +} + +#[derive(Serialize)] +pub struct PointWithMonitor { + #[serde(flatten)] + pub point: Point, + pub point_monitor: Option, +} + +pub async fn get_point_list( + State(state): State, + Query(query): Query, +) -> Result { + let pool = &state.pool; + let points: Vec = match query.source_id { + Some(source_id) => { + sqlx::query_as::<_, Point>( + r#" + SELECT p.* + FROM point p + INNER JOIN node n ON p.node_id = n.id + WHERE n.source_id = $1 + ORDER BY p.created_at + "#, + ) + .bind(source_id) + .fetch_all(pool) + .await? + } + None => { + sqlx::query_as::<_, Point>( + r#"SELECT * FROM point ORDER BY created_at"#, + ) + .fetch_all(pool) + .await? + } + }; + + let monitor_guard = state + .connection_manager + .get_point_monitor_data_read_guard() + .await; + + let resp: Vec = points + .into_iter() + .map(|point| { + let point_monitor = monitor_guard + .get(&point.id) + .cloned() + .map(|m| crate::telemetry::WsPointMonitorInfo::from(&m)); + PointWithMonitor { point, point_monitor } + }) + .collect(); + + Ok(Json(resp)) +} +/// Get a point by id. +pub async fn get_point( + State(state): State, + Path(point_id): Path, +) -> Result { + let pool = &state.pool; + let point = sqlx::query_as::<_, Point>( + r#"SELECT * FROM point WHERE id = $1"#, + ) + .bind(point_id) + .fetch_optional(pool) + .await?; + + Ok(Json(point)) +} + + +/// Request payload for updating editable point fields. +#[derive(Deserialize, Validate)] +pub struct UpdatePointReq { + pub name: Option, + pub description: Option, + pub unit: Option, + pub tag_id: Option, +} + +/// Request payload for batch setting point tags. +#[derive(Deserialize, Validate)] +pub struct BatchSetPointTagsReq { + pub point_ids: Vec, + pub tag_id: Option, +} + +/// Update point metadata (name/description/unit only). +pub async fn update_point( + State(state): State, + Path(point_id): Path, + Json(payload): Json, +) -> Result { + payload.validate()?; + + let pool = &state.pool; + + if payload.name.is_none() && payload.description.is_none() && payload.unit.is_none() && payload.tag_id.is_none() { + return Ok(Json(serde_json::json!({"ok_msg": "No fields to update"}))); + } + + // If tag_id is provided, ensure tag exists. + if let Some(tag_id) = payload.tag_id { + let tag_exists = sqlx::query( + r#"SELECT 1 FROM tag WHERE id = $1"#, + ) + .bind(tag_id) + .fetch_optional(pool) + .await? + .is_some(); + + if !tag_exists { + return Err(ApiErr::NotFound("Tag not found".to_string(), None)); + } + } + + // Ensure target point exists. + let existing_point = sqlx::query_as::<_, Point>( + r#"SELECT * FROM point WHERE id = $1"#, + ) + .bind(point_id) + .fetch_optional(pool) + .await?; + if existing_point.is_none() { + return Err(ApiErr::NotFound("Point not found".to_string(), None)); + } + + // Build dynamic UPDATE SQL for provided fields. + let mut updates = Vec::new(); + let mut values: Vec = Vec::new(); + let mut param_count = 1; + + if let Some(name) = &payload.name { + updates.push(format!("name = ${}", param_count)); + values.push(name.clone()); + param_count += 1; + } + + if let Some(description) = &payload.description { + updates.push(format!("description = ${}", param_count)); + values.push(description.clone()); + param_count += 1; + } + + if let Some(unit) = &payload.unit { + updates.push(format!("unit = ${}", param_count)); + values.push(unit.clone()); + param_count += 1; + } + + if let Some(tag_id) = &payload.tag_id { + updates.push(format!("tag_id = ${}", param_count)); + values.push(tag_id.to_string()); + param_count += 1; + } + + // Always update timestamp. + updates.push("updated_at = NOW()".to_string()); + + let sql = format!( + "UPDATE point SET {} WHERE id = ${}", + updates.join(", "), + param_count + ); + values.push(point_id.to_string()); + + let mut query = sqlx::query(&sql); + for value in &values { + query = query.bind(value); + } + + query.execute(pool).await?; + + Ok(Json(serde_json::json!({"ok_msg": "Point updated successfully"}))) +} + +/// Batch set point tags. +pub async fn batch_set_point_tags( + State(state): State, + Json(payload): Json, +) -> Result { + payload.validate()?; + + if payload.point_ids.is_empty() { + return Err(ApiErr::BadRequest("point_ids cannot be empty".to_string(), None)); + } + + let pool = &state.pool; + + // If tag_id is provided, ensure tag exists. + if let Some(tag_id) = payload.tag_id { + let tag_exists = sqlx::query( + r#"SELECT 1 FROM tag WHERE id = $1"#, + ) + .bind(tag_id) + .fetch_optional(pool) + .await? + .is_some(); + + if !tag_exists { + return Err(ApiErr::NotFound("Tag not found".to_string(), None)); + } + } + + // Check which points exist + let existing_points: Vec = sqlx::query( + r#"SELECT id FROM point WHERE id = ANY($1)"#, + ) + .bind(&payload.point_ids) + .fetch_all(pool) + .await? + .into_iter() + .map(|row: sqlx::postgres::PgRow| row.get::("id")) + .collect(); + + if existing_points.is_empty() { + return Err(ApiErr::NotFound("No valid points found".to_string(), None)); + } + + // Update tag_id for all existing points + let result = sqlx::query( + r#"UPDATE point SET tag_id = $1, updated_at = NOW() WHERE id = ANY($2)"#, + ) + .bind(payload.tag_id) + .bind(&existing_points) + .execute(pool) + .await?; + + Ok(Json(serde_json::json!({ + "ok_msg": "Point tags updated successfully", + "updated_count": result.rows_affected() + }))) +} + +/// Delete one point by id. +pub async fn delete_point( + State(state): State, + Path(point_id): Path, +) -> Result { + let pool = &state.pool; + + let source_id = { + let grouped = crate::service::get_points_grouped_by_source(pool, &[point_id]).await?; + grouped.keys().next().copied() + }; + + // Ensure target point exists. + let existing_point = sqlx::query_as::<_, Point>( + r#"SELECT * FROM point WHERE id = $1"#, + ) + .bind(point_id) + .fetch_optional(pool) + .await?; + if existing_point.is_none() { + return Err(ApiErr::NotFound("Point not found".to_string(), None)); + } + + // Delete point. + sqlx::query( + r#"delete from point WHERE id = $1"#, + ) + .bind(point_id) + .execute(pool) + .await?; + + if let Some(source_id) = source_id { + if let Err(e) = state + .event_manager + .send(crate::event::ReloadEvent::PointDeleteBatch { + source_id, + point_ids: vec![point_id], + }) + { + tracing::error!("Failed to send PointDeleteBatch event: {}", e); + } + } + + Ok(Json(serde_json::json!({"ok_msg": "Point deleted successfully"}))) +} + +#[derive(Deserialize, Validate)] +/// Request payload for batch point creation from node ids. +pub struct BatchCreatePointsReq { + pub node_ids: Vec, +} + +#[derive(Serialize)] +/// Response payload for batch point creation. +pub struct BatchCreatePointsRes { + pub success_count: usize, + pub failed_count: usize, + pub failed_node_ids: Vec, + pub created_point_ids: Vec, +} + +/// Batch create points by node ids. +pub async fn batch_create_points( + State(state): State, + Json(payload): Json, +) -> Result { + payload.validate()?; + + let pool = &state.pool; + + if payload.node_ids.is_empty() { + return Err(ApiErr::BadRequest("node_ids cannot be empty".to_string(), None)); + } + + let mut success_count = 0; + let mut failed_count = 0; + let mut failed_node_ids = Vec::new(); + let mut created_point_ids = Vec::new(); + + // Use one transaction for the full batch. + let mut tx = pool.begin().await?; + + for node_id in payload.node_ids { + // Ensure node exists. + let node_exists = sqlx::query( + r#"SELECT 1 FROM node WHERE id = $1"#, + ) + .bind(node_id) + .fetch_optional(&mut *tx) + .await? + .is_some(); + + if !node_exists { + failed_count += 1; + failed_node_ids.push(node_id); + continue; + } + + // Skip nodes that already have a point. + let point_exists = sqlx::query( + r#"SELECT 1 FROM point WHERE node_id = $1"#, + ) + .bind(node_id) + .fetch_optional(&mut *tx) + .await? + .is_some(); + + if point_exists { + continue; + } + + // Use node browse_name as default point name. + let node_info = sqlx::query_as::<_, Node>( + r#"SELECT * FROM node WHERE id = $1"#, + ) + .bind(node_id) + .fetch_optional(&mut *tx) + .await?; + + let name = match node_info { + Some(node) => node.browse_name.clone(), + None => format!("Point_{}", node_id), + }; + + let new_id = Uuid::new_v4(); + + sqlx::query( + r#" + INSERT INTO point (id, node_id, name) + VALUES ($1, $2, $3) + "# + ) + .bind(new_id) + .bind(node_id) + .bind(&name) + .execute(&mut *tx) + .await?; + + success_count += 1; + created_point_ids.push(new_id); + } + + // Commit the transaction. + tx.commit().await?; + + // Emit grouped create events by source. + if !created_point_ids.is_empty() { + let grouped = crate::service::get_points_grouped_by_source(pool, &created_point_ids).await?; + for (source_id, points) in grouped { + let point_ids: Vec = points.into_iter().map(|p| p.point_id).collect(); + if let Err(e) = state + .event_manager + .send(crate::event::ReloadEvent::PointCreateBatch { source_id, point_ids }) + { + tracing::error!("Failed to send PointCreateBatch event: {}", e); + } + } + } + + Ok(Json(BatchCreatePointsRes { + success_count, + failed_count, + failed_node_ids, + created_point_ids, + })) +} + +#[derive(Deserialize, Validate)] +/// Request payload for batch point deletion. +pub struct BatchDeletePointsReq { + pub point_ids: Vec, +} + +#[derive(Serialize)] +/// Response payload for batch point deletion. +pub struct BatchDeletePointsRes { + pub deleted_count: u64, +} + +/// Batch delete points and emit grouped delete events by source. +pub async fn batch_delete_points( + State(state): State, + Json(payload): Json, +) -> Result { + payload.validate()?; + + if payload.point_ids.is_empty() { + return Err(ApiErr::BadRequest("point_ids cannot be empty".to_string(), None)); + } + + let pool = &state.pool; + let point_ids = payload.point_ids; + + let grouped = crate::service::get_points_grouped_by_source(pool, &point_ids).await?; + let existing_point_ids: Vec = grouped + .values() + .flat_map(|points| points.iter().map(|p| p.point_id)) + .collect(); + + if existing_point_ids.is_empty() { + return Ok(Json(BatchDeletePointsRes { deleted_count: 0 })); + } + + let result = sqlx::query( + r#"DELETE FROM point WHERE id = ANY($1)"#, + ) + .bind(&existing_point_ids) + .execute(pool) + .await?; + + for (source_id, points) in grouped { + let ids: Vec = points.into_iter().map(|p| p.point_id).collect(); + if let Err(e) = state + .event_manager + .send(crate::event::ReloadEvent::PointDeleteBatch { + source_id, + point_ids: ids, + }) + { + tracing::error!("Failed to send PointDeleteBatch event: {}", e); + } + } + + Ok(Json(BatchDeletePointsRes { + deleted_count: result.rows_affected(), + })) +} + + + + + +pub async fn batch_set_point_value( + State(state): State, + headers: HeaderMap, + Json(payload): Json, +) -> Result { + let write_key = headers + .get("X-Write-Key") + .and_then(|v| v.to_str().ok()) + .unwrap_or_default(); + + if !state.config.verify_write_key(write_key) { + return Err(ApiErr::Forbidden( + "write permission denied".to_string(), + Some(serde_json::json!({ + "hint": "set WRITE_API_KEY and pass header X-Write-Key" + })), + )); + } + + let result = state.connection_manager.write_point_values_batch(payload) + .await + .map_err(|e| ApiErr::Internal(e, None))?; + Ok(Json(result)) +} diff --git a/src/handler/source.rs b/src/handler/source.rs new file mode 100644 index 0000000..9a98fc8 --- /dev/null +++ b/src/handler/source.rs @@ -0,0 +1,583 @@ +use axum::{Json, extract::{Path, State}, http::StatusCode, response::IntoResponse}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; +use validator::Validate; +use opcua::types::{ + NodeId, BrowseDescription, ReferenceDescription, + BrowseDirection as OpcuaBrowseDirection, Identifier, ReadValueId, AttributeId, NumericRange, TimestampsToReturn, Variant +}; +use opcua::types::ReferenceTypeId; +use opcua::client::Session; +use std::collections::{HashMap, VecDeque}; + +use crate::util::response::ApiErr; + +use crate::{AppState, model::{Node, Source}}; +use anyhow::{Context}; + +// 树节点结构体 +#[derive(Debug, Serialize, Clone)] +pub struct TreeNode { + pub id: Uuid, + pub source_id: Uuid, + pub external_id: String, + pub namespace_uri: Option, + pub namespace_index: Option, + pub identifier_type: Option, + pub identifier: Option, + pub browse_name: String, + pub display_name: Option, + pub node_class: String, + pub parent_id: Option, + pub children: Vec, +} + +impl TreeNode { + fn from_node(node: Node) -> Self { + TreeNode { + id: node.id, + source_id: node.source_id, + external_id: node.external_id, + namespace_uri: node.namespace_uri, + namespace_index: node.namespace_index, + identifier_type: node.identifier_type, + identifier: node.identifier, + browse_name: node.browse_name, + display_name: node.display_name, + node_class: node.node_class, + parent_id: node.parent_id, + children: Vec::new(), + } + } +} + + +// 带连接状态的Source响应结构体 +#[derive(Debug, Serialize, Clone)] +pub struct SourceWithStatus { + #[serde(flatten)] + pub source: Source, + pub is_connected: bool, + pub last_error: Option, + #[serde(serialize_with = "crate::util::datetime::option_utc_to_local_str")] + pub last_time: Option>, +} + +pub async fn get_source_list(State(state): State) -> Result { + let pool = &state.pool; + let sources: Vec = sqlx::query_as( + r#"SELECT * FROM source where enabled is true"#, + ).fetch_all(pool).await?; + + // 获取所有连接状态 + let status_map: std::collections::HashMap, Option>)> = + state.connection_manager.get_all_status().await + .into_iter() + .map(|(source_id, s)| (source_id, (s.is_connected, s.last_error, Some(s.last_time)))) + .collect(); + + // 组合Source和连接状态 + let sources_with_status: Vec = sources + .into_iter() + .map(|source| { + let (is_connected, last_error, last_time) = status_map + .get(&source.id) + .map(|(connected, error, time)| (*connected, error.clone(), *time)) + .unwrap_or((false, None, None)); + SourceWithStatus { + source, + is_connected, + last_error, + last_time, + } + }) + .collect(); + + Ok(Json(sources_with_status)) +} + +pub async fn get_node_tree( + State(state): State, + Path(source_id): Path, +) -> Result { + let pool = &state.pool; + + // 查询所有属于该source的节点 + let nodes: Vec = sqlx::query_as::<_, Node>( + r#"SELECT * FROM node WHERE source_id = $1 ORDER BY created_at"#, + ) + .bind(source_id) + .fetch_all(pool) + .await?; + + // 构建节点树 + let tree = build_node_tree(nodes); + + Ok(Json(tree)) +} + +fn build_node_tree(nodes: Vec) -> Vec { + let mut node_map: HashMap = HashMap::new(); + let mut children_map: HashMap> = HashMap::new(); + let mut roots: Vec = Vec::new(); + + // ① 转换 + 记录 parent 关系 + for node in nodes { + let tree_node = TreeNode::from_node(node); + let id = tree_node.id; + + if let Some(pid) = tree_node.parent_id { + children_map.entry(pid).or_default().push(id); + } else { + roots.push(id); + } + + node_map.insert(id, tree_node); + } + + // ② 递归构建 + fn attach_children( + id: Uuid, + node_map: &mut HashMap, + children_map: &HashMap>, + ) -> TreeNode { + let mut node = node_map.remove(&id).unwrap(); + + if let Some(child_ids) = children_map.get(&id) { + for &cid in child_ids { + let child = attach_children(cid, node_map, children_map); + node.children.push(child); + } + } + + node + } + + // ③ 生成最终树 + roots + .into_iter() + .map(|rid| attach_children(rid, &mut node_map, &children_map)) + .collect() +} + + +#[derive(Deserialize, Validate)] +pub struct CreateSourceReq { + pub name: String, + pub endpoint: String, + pub enabled: bool, +} + +#[derive(Serialize)] +pub struct CreateSourceRes { + pub id: Uuid, +} + +pub async fn create_source( + State(state): State, + Json(payload): Json, +) -> Result { + payload.validate()?; + + let pool = &state.pool; + let new_id = Uuid::new_v4(); + + sqlx::query( + r#"INSERT INTO source (id, name, endpoint, enabled, protocol) VALUES ($1, $2, $3, $4, $5)"#, + ) + .bind(new_id) + .bind(&payload.name) + .bind(&payload.endpoint) + .bind(payload.enabled) + .bind("opcua") //默认opcua协议 + .execute(pool) + .await?; + + // 触发 SourceCreate 事件 + let _ = state.event_manager.send(crate::event::ReloadEvent::SourceCreate { source_id: new_id }); + + Ok((StatusCode::CREATED, Json(CreateSourceRes { id: new_id }))) +} + +pub async fn delete_source( + State(state): State, + Path(source_id): Path, +) -> Result { + let pool = &state.pool; + + // 删除source + let result = sqlx::query("DELETE FROM source WHERE id = $1") + .bind(source_id) + .execute(pool) + .await?; + + // 检查是否删除了记录 + if result.rows_affected() == 0 { + return Err(ApiErr::NotFound(format!("Source with id {} not found", source_id), None)); + } + + // 触发 SourceDelete 事件 + let _ = state.event_manager.send(crate::event::ReloadEvent::SourceDelete { source_id }); + + Ok(StatusCode::NO_CONTENT) +} + +pub async fn browse_and_save_nodes( + State(state): State, + Path(source_id): Path, +) -> Result { + + let pool = &state.pool; + + // 确认 source 存在 + sqlx::query("SELECT 1 FROM source WHERE id = $1") + .bind(source_id) + .fetch_one(pool) + .await?; + + let session = state.connection_manager + .get_session(source_id) + .await + .ok_or_else(|| anyhow::anyhow!("Source not connected"))?; + + // 读取 namespace 映射 + let namespace_map = load_namespace_map(&session).await + .context("Failed to load namespace map")?; + + // 开启事务(整次浏览一个事务) + let mut tx = pool.begin().await + .context("Failed to begin transaction")?; + + let mut processed_nodes: HashMap = HashMap::new(); + let mut queue: VecDeque<(NodeId, Option)> = VecDeque::new(); + + queue.push_back((NodeId::objects_folder_id(), None)); + + while let Some((node_id, parent_id)) = queue.pop_front() { + browse_single_node( + &session, + &mut tx, + source_id, + &node_id, + parent_id, + &namespace_map, + &mut processed_nodes, + &mut queue, + ).await + .with_context(|| format!("Failed to browse node: {:?}", node_id))?; + } + + tx.commit().await + .context("Failed to commit transaction")?; + + Ok(Json(serde_json::json!({ + "ok_msg": "Browse completed", + "total_nodes": processed_nodes.len() + }))) +} + +//////////////////////////////////////////////////////////////// +// 浏览单个节点(含 continuation) +//////////////////////////////////////////////////////////////// + +async fn browse_single_node( + session: &Session, + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + source_id: Uuid, + node_id: &NodeId, + parent_id: Option, + namespace_map: &HashMap, + processed_nodes: &mut HashMap, + queue: &mut VecDeque<(NodeId, Option)>, +) -> anyhow::Result<()> { + + let browse_desc = BrowseDescription { + node_id: node_id.clone(), + browse_direction: OpcuaBrowseDirection::Forward, + reference_type_id: ReferenceTypeId::HierarchicalReferences.into(), + include_subtypes: true, + node_class_mask: 0, + result_mask: 0x3F, + }; + + let mut results = session.browse(&[browse_desc], 0u32, None).await + .context("Failed to browse node")?; + + loop { + let result = &results[0]; + + if let Some(refs) = &result.references { + for ref_desc in refs { + process_reference( + ref_desc, + tx, + source_id, + parent_id, + namespace_map, + processed_nodes, + queue, + ).await + .with_context(|| format!("Failed to process reference: {:?}", ref_desc.node_id.node_id))?; + } + } + + if !result.continuation_point.is_null() { + let cp = result.continuation_point.clone(); + results = session.browse_next(false, &[cp]).await + .context("Failed to browse next")?; + } else { + break; + } + } + + Ok(()) +} + +//////////////////////////////////////////////////////////////// +// 处理单个 Reference(核心优化版) +//////////////////////////////////////////////////////////////// + +async fn process_reference( + ref_desc: &ReferenceDescription, + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + source_id: Uuid, + parent_id: Option, + namespace_map: &HashMap, + processed_nodes: &mut HashMap, + queue: &mut VecDeque<(NodeId, Option)>, +) -> anyhow::Result<()> { + + let node_id_obj = &ref_desc.node_id.node_id; + let node_id_str = node_id_obj.to_string(); + + // 内存去重 + if processed_nodes.contains_key(&node_id_str) { + return Ok(()); + } + + let (namespace_index, identifier_type, identifier) = + parse_node_id(node_id_obj); + + let namespace_uri = namespace_map + .get(&(namespace_index.unwrap_or(0) as i32)) + .cloned() + .unwrap_or_default(); + + let browse_name = ref_desc.browse_name.name.to_string(); + let display_name = ref_desc.display_name.text.to_string(); + let node_class = format!("{:?}", ref_desc.node_class); + + let now = Utc::now(); + let node_uuid = Uuid::new_v4(); + + // ?? 关键优化:直接 UPSERT(避免 SELECT) + // 注意:如果 parent_id 存在,则必须确保该父节点已存在于数据库中 + // 否则会触发外键约束失败 + if parent_id.is_some() { + // 检查父节点是否已存在于数据库中 + let parent_exists = sqlx::query( + r#"SELECT 1 FROM node WHERE id = $1"#, + ) + .bind(parent_id.unwrap()) + .fetch_optional(tx.as_mut()) + .await?; + + if parent_exists.is_none() { + // 如果父节点不存在,则暂时不设置 parent_id + // 这样可以避免外键约束失败 + sqlx::query( + r#" + INSERT INTO node ( + id, + source_id, + external_id, + namespace_uri, + namespace_index, + identifier_type, + identifier, + browse_name, + display_name, + node_class, + parent_id, + ) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NULL) + ON CONFLICT(source_id, external_id) DO UPDATE SET + namespace_uri = excluded.namespace_uri, + namespace_index = excluded.namespace_index, + identifier_type = excluded.identifier_type, + identifier = excluded.identifier, + browse_name = excluded.browse_name, + display_name = excluded.display_name, + node_class = excluded.node_class, + updated_at = NOW() + "# + ) + .bind(node_uuid) + .bind(source_id) + .bind(&node_id_str) + .bind(&namespace_uri) + .bind(namespace_index.map(|v| v as i32)) + .bind(&identifier_type) + .bind(&identifier) + .bind(&browse_name) + .bind(&display_name) + .bind(&node_class) + .execute(tx.as_mut()) + .await + .context("Failed to execute UPSERT query")?; + } else { + // 如果父节点存在,则正常设置 parent_id + sqlx::query( + r#" + INSERT INTO node ( + id, + source_id, + external_id, + namespace_uri, + namespace_index, + identifier_type, + identifier, + browse_name, + display_name, + node_class, + parent_id, + created_at, + updated_at + ) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,NOW(),NOW()) + ON CONFLICT(source_id, external_id) DO UPDATE SET + namespace_uri = excluded.namespace_uri, + namespace_index = excluded.namespace_index, + identifier_type = excluded.identifier_type, + identifier = excluded.identifier, + browse_name = excluded.browse_name, + display_name = excluded.display_name, + node_class = excluded.node_class, + parent_id = excluded.parent_id, + updated_at = NOW() + "# + ) + .bind(node_uuid) + .bind(source_id) + .bind(&node_id_str) + .bind(&namespace_uri) + .bind(namespace_index.map(|v| v as i32)) + .bind(&identifier_type) + .bind(&identifier) + .bind(&browse_name) + .bind(&display_name) + .bind(&node_class) + .bind(parent_id) + .bind(now) + .bind(now) + .execute(tx.as_mut()) + .await + .context("Failed to execute UPSERT query")?; + } + } else { + // 如果没有 parent_id,则正常插入 + sqlx::query( + r#" + INSERT INTO node ( + id, + source_id, + external_id, + namespace_uri, + namespace_index, + identifier_type, + identifier, + browse_name, + display_name, + node_class, + parent_id + ) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NULL) + ON CONFLICT(source_id, external_id) DO UPDATE SET + namespace_uri = excluded.namespace_uri, + namespace_index = excluded.namespace_index, + identifier_type = excluded.identifier_type, + identifier = excluded.identifier, + browse_name = excluded.browse_name, + display_name = excluded.display_name, + node_class = excluded.node_class, + updated_at = NOW() + "# + ) + .bind(node_uuid) + .bind(source_id) + .bind(&node_id_str) + .bind(&namespace_uri) + .bind(namespace_index.map(|v| v as i32)) + .bind(&identifier_type) + .bind(&identifier) + .bind(&browse_name) + .bind(&display_name) + .bind(&node_class) + .execute(tx.as_mut()) + .await + .context("Failed to execute UPSERT query")?; + } + + processed_nodes.insert(node_id_str.clone(), ()); + queue.push_back((node_id_obj.clone(), Some(node_uuid))); + + Ok(()) +} + +//////////////////////////////////////////////////////////////// +// 解析 NodeId +//////////////////////////////////////////////////////////////// + +fn parse_node_id(node_id: &NodeId) -> (Option, Option, String) { + + let namespace_index = Some(node_id.namespace); + + let (identifier_type, identifier) = match &node_id.identifier { + Identifier::Numeric(i) => ("i".to_string(), i.to_string()), + Identifier::String(s) => ("s".to_string(), s.to_string()), + Identifier::Guid(g) => ("g".to_string(), g.to_string()), + Identifier::ByteString(b) => ("b".to_string(), format!("{:?}", b)), + }; + + (namespace_index, Some(identifier_type), identifier) +} + +//////////////////////////////////////////////////////////////// +// 读取 NamespaceArray +//////////////////////////////////////////////////////////////// + +async fn load_namespace_map( + session: &Session, +) -> anyhow::Result> { + // 读取命名空间数组节点 + let ns_node = NodeId::new(0, 2255); + let read_request = ReadValueId { + node_id: ns_node, + attribute_id: AttributeId::Value as u32, + index_range: NumericRange::None, + data_encoding: Default::default(), + }; + + // 执行读取操作 + let result = session.read(&[read_request], TimestampsToReturn::Neither, 0f64).await + .context("Failed to read namespace map")?; + + // 解析并构建命名空间映射 + let mut map = HashMap::new(); + if let Some(value) = &result[0].value { + if let Variant::Array(array) = value { + for (i, item) in array.values.iter().enumerate() { + if let Ok(index) = i32::try_from(i) { + if let Variant::String(uri) = item { + map.insert(index, uri.to_string()); + } + } + } + } + } + + Ok(map) +} + + diff --git a/src/handler/tag.rs b/src/handler/tag.rs new file mode 100644 index 0000000..06a6e95 --- /dev/null +++ b/src/handler/tag.rs @@ -0,0 +1,102 @@ +use axum::{Json, extract::{Path, State}, http::StatusCode, response::IntoResponse}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; +use validator::Validate; + +use crate::util::response::ApiErr; +use crate::{AppState}; + +/// 获取所有标签 +pub async fn get_tag_list( + State(state): State, +) -> Result { + let tags = crate::service::get_all_tags(&state.pool).await?; + Ok(Json(tags)) +} + +/// 获取标签下的点位信息 +pub async fn get_tag_points( + State(state): State, + Path(tag_id): Path, +) -> Result { + let points = crate::service::get_tag_points(&state.pool, tag_id).await?; + Ok(Json(points)) +} + +#[derive(Debug, Deserialize, Validate)] +pub struct CreateTagReq { + #[validate(length(min = 1, max = 100))] + pub name: String, + pub description: Option, + pub point_ids: Option>, +} + +#[derive(Debug, Deserialize, Validate)] +pub struct UpdateTagReq { + #[validate(length(min = 1, max = 100))] + pub name: Option, + pub description: Option, + pub point_ids: Option>, +} + +/// 创建标签 +pub async fn create_tag( + State(state): State, + Json(payload): Json, +) -> Result { + payload.validate()?; + + let point_ids = payload.point_ids.as_deref().unwrap_or(&[]); + let tag_id = crate::service::create_tag( + &state.pool, + &payload.name, + payload.description.as_deref(), + point_ids, + ).await?; + + Ok((StatusCode::CREATED, Json(serde_json::json!({ + "id": tag_id, + "ok_msg": "Tag created successfully" + })))) +} + +/// 更新标签 +pub async fn update_tag( + State(state): State, + Path(tag_id): Path, + Json(payload): Json, +) -> Result { + payload.validate()?; + + // 检查标签是否存在 + let exists = crate::service::get_tag_by_id(&state.pool, tag_id).await?; + if exists.is_none() { + return Err(ApiErr::NotFound("Tag not found".to_string(), None)); + } + + crate::service::update_tag( + &state.pool, + tag_id, + payload.name.as_deref(), + payload.description.as_deref(), + payload.point_ids.as_deref(), + ).await?; + + Ok(Json(serde_json::json!({ + "ok_msg": "Tag updated successfully" + }))) +} + +/// 删除标签 +pub async fn delete_tag( + State(state): State, + Path(tag_id): Path, +) -> Result { + let deleted = crate::service::delete_tag(&state.pool, tag_id).await?; + + if !deleted { + return Err(ApiErr::NotFound("Tag not found".to_string(), None)); + } + + Ok(StatusCode::NO_CONTENT) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..05b0977 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,150 @@ +mod model; +mod config; +mod util; +mod db; +mod handler; +mod middleware; +mod connection; +mod event; +mod service; +mod websocket; +mod telemetry; +use config::AppConfig; +use tower_http::cors::{Any, CorsLayer}; +use db::init_database; +use middleware::simple_logger; +use connection::ConnectionManager; +use event::EventManager; +use std::sync::Arc; +use axum::{ + routing::get, + Router, +}; + + +#[derive(Clone)] +pub struct AppState { + pub config: AppConfig, + pub pool: sqlx::PgPool, + pub connection_manager: Arc, + pub event_manager: Arc, + pub ws_manager: Arc, +} +#[tokio::main] +async fn main() { + dotenv::dotenv().ok(); + util::log::init_logger(); + + let config = AppConfig::from_env().expect("Failed to load configuration"); + let pool = init_database(&config.database_url).await.expect("Failed to initialize database"); + + let mut connection_manager = ConnectionManager::new_with_pool(pool.clone()); + let ws_manager = Arc::new(websocket::WebSocketManager::new()); + let event_manager = Arc::new(EventManager::new( + pool.clone(), + Arc::new(connection_manager.clone()), + Some(ws_manager.clone()), + )); + connection_manager.set_event_manager(event_manager.clone()); + let connection_manager = Arc::new(connection_manager); + + // Connect to all enabled sources + let sources = service::get_all_enabled_sources(&pool) + .await + .expect("Failed to fetch sources"); + + for source in sources { + tracing::info!("Connecting to source: {} ({})", source.name, source.endpoint); + match connection_manager.connect_from_source(&pool, source.id).await { + Ok(_) => { + tracing::info!("Successfully connected to source: {}", source.name); + // Subscribe to points for this source + match connection_manager + .subscribe_points_from_source(source.id, None, &pool) + .await + { + Ok(stats) => { + let subscribed = *stats.get("subscribed").unwrap_or(&0); + let polled = *stats.get("polled").unwrap_or(&0); + let total = *stats.get("total").unwrap_or(&0); + tracing::info!( + "Point subscribe setup for source {}: subscribed={}, polled={}, total={}", + source.name, + subscribed, + polled, + total + ); + } + Err(e) => { + tracing::error!("Failed to subscribe to points for source {}: {}", source.name, e); + } + } + } + Err(e) => { + tracing::error!("Failed to connect to source {}: {}", source.name, e); + } + } + } + + let state = AppState { + config: config.clone(), + pool, + connection_manager: connection_manager.clone(), + event_manager, + ws_manager, + }; + let app = build_router(state.clone()); + let addr = format!("{}:{}", config.server_host, config.server_port); + tracing::info!("Starting server at http://{}", addr); + let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); + + // comment fixed + let shutdown_signal = async move{ + tokio::signal::ctrl_c() + .await + .expect("Failed to install Ctrl+C handler"); + tracing::info!("Received shutdown signal, closing all connections..."); + connection_manager.disconnect_all().await; + tracing::info!("All connections closed"); + }; + + axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal) + .await + .unwrap(); +} + +fn build_router(state: AppState) -> Router { + let all_route = Router::new() + .route("/api/source", get(handler::source::get_source_list).post(handler::source::create_source)) + .route("/api/source/{source_id}", axum::routing::delete(handler::source::delete_source)) + .route("/api/source/{source_id}/browse", axum::routing::post(handler::source::browse_and_save_nodes)) + .route("/api/source/{source_id}/node-tree", get(handler::source::get_node_tree)) + .route("/api/point", get(handler::point::get_point_list)) + .route( + "/api/point/value/batch", + axum::routing::post(handler::point::batch_set_point_value), + ) + .route( + "/api/point/batch", + axum::routing::post(handler::point::batch_create_points) + .delete(handler::point::batch_delete_points), + ) + .route("/api/point/{point_id}", get(handler::point::get_point).put(handler::point::update_point).delete(handler::point::delete_point)) + .route("/api/point/batch/set-tags", put(handler::point::batch_set_point_tags)) + .route("/api/tag", get(handler::tag::get_tag_list).post(handler::tag::create_tag)) + .route("/api/tag/{tag_id}", get(handler::tag::get_tag_points).put(handler::tag::update_tag).delete(handler::tag::delete_tag)); + + Router::new() + .merge(all_route) + .route("/ws/public", get(websocket::public_websocket_handler)) + .route("/ws/client/{client_id}", get(websocket::client_websocket_handler)) + .layer(axum::middleware::from_fn(simple_logger)) + .layer( + CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any), + ) + .with_state(state) +} diff --git a/src/middleware.rs b/src/middleware.rs new file mode 100644 index 0000000..ebc4b84 --- /dev/null +++ b/src/middleware.rs @@ -0,0 +1,37 @@ +use axum::{ + body::Body, + http::Request, + middleware::Next, + response::Response, +}; +use std::time::Instant; + +pub async fn simple_logger( + req: Request, + next: Next, +) -> Response { + // 直接获取字符串引用,不用克隆 + let method = req.method().to_string(); + let uri = req.uri().to_string(); // Uri 的 to_string() 创建新字符串 + + let start = Instant::now(); + let res = next.run(req).await; + let duration = start.elapsed(); + let status = res.status(); + match status.as_u16() { + 100..=399 => { + tracing::info!("{} {} {} {:?}", method, uri, status, duration); + } + 400..=499 => { + tracing::warn!("{} {} {} {:?}", method, uri, status, duration); + } + 500..=599 => { + tracing::error!("{} {} {} {:?}", method, uri, status, duration); + } + _ => { + tracing::warn!("{} {} {} {:?}", method, uri, status, duration); + } + } + + res +} diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000..a13e1e9 --- /dev/null +++ b/src/model.rs @@ -0,0 +1,120 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use uuid::Uuid; +use crate::util::datetime::utc_to_local_str; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ScanMode { + Poll, + Subscribe, +} + +impl ScanMode { + pub fn as_str(&self) -> &'static str { + match self { + ScanMode::Poll => "poll", + ScanMode::Subscribe => "subscribe", + } + } +} + +impl From for String { + fn from(mode: ScanMode) -> Self { + mode.as_str().to_string() + } +} + +impl std::fmt::Display for ScanMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl std::str::FromStr for ScanMode { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "poll" => Ok(ScanMode::Poll), + "subscribe" => Ok(ScanMode::Subscribe), + _ => Err(format!("Invalid scan mode: {}", s)), + } + } +} + +#[derive(Debug, Serialize, Deserialize, FromRow, Clone)] +pub struct Source { + pub id: Uuid, + pub name: String, + pub protocol: String, // opcua, modbus + pub endpoint: String, + pub security_policy: Option, + pub security_mode: Option, + pub username: Option, + pub password: Option, + pub enabled: bool, + #[serde(serialize_with = "utc_to_local_str")] + pub created_at: DateTime, + #[serde(serialize_with = "utc_to_local_str")] + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize, FromRow)] +#[allow(dead_code)] +pub struct Node { + pub id: Uuid, + pub source_id: Uuid, + pub external_id: String, // ns=2;s=Temperature + + // comment fixed + pub namespace_uri: Option, + pub namespace_index: Option, + pub identifier_type: Option, // i/s/g/b + pub identifier: Option, + + pub browse_name: String, + pub display_name: Option, + pub node_class: String, // Object/Variable/Method coil/input topic + pub parent_id: Option, + #[serde(serialize_with = "utc_to_local_str")] + pub created_at: DateTime, + #[serde(serialize_with = "utc_to_local_str")] + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize, FromRow)] +#[allow(dead_code)] +pub struct Point { + pub id: Uuid, + pub node_id: Uuid, + pub name: String, + pub description: Option, + pub unit: Option, + pub scan_interval_s: i32, // s + pub tag_id: Option, + #[serde(serialize_with = "utc_to_local_str")] + pub created_at: DateTime, + #[serde(serialize_with = "utc_to_local_str")] + pub updated_at: DateTime, +} + +#[derive(Debug, Clone)] +pub struct PointSubscriptionInfo { + pub point_id: Uuid, + pub external_id: String, + pub scan_interval_s: i32, +} + +#[derive(Debug, Serialize, Deserialize, FromRow, Clone)] +pub struct Tag { + pub id: Uuid, + pub name: String, + pub description: Option, + #[serde(serialize_with = "utc_to_local_str")] + pub created_at: DateTime, + #[serde(serialize_with = "utc_to_local_str")] + pub updated_at: DateTime, +} + diff --git a/src/service.rs b/src/service.rs new file mode 100644 index 0000000..92518fc --- /dev/null +++ b/src/service.rs @@ -0,0 +1,300 @@ +use crate::model::{PointSubscriptionInfo, Source}; +use sqlx::{PgPool, query_as}; + +pub async fn get_enabled_source( + pool: &PgPool, + source_id: uuid::Uuid, +) -> Result, sqlx::Error> { + query_as::<_, Source>("SELECT * FROM source WHERE id = $1 AND enabled = true") + .bind(source_id) + .fetch_optional(pool) + .await +} + +pub async fn get_all_enabled_sources(pool: &PgPool) -> Result, sqlx::Error> { + query_as::<_, Source>("SELECT * FROM source WHERE enabled = true") + .fetch_all(pool) + .await +} + +pub async fn get_points_grouped_by_source( + pool: &PgPool, + point_ids: &[uuid::Uuid], +) -> Result>, sqlx::Error> { + if point_ids.is_empty() { + return Ok(std::collections::HashMap::new()); + } + + let rows = sqlx::query( + r#" + SELECT + p.id as point_id, + n.source_id, + n.external_id, + p.scan_interval_s + FROM point p + INNER JOIN node n ON p.node_id = n.id + WHERE p.id = ANY($1) + ORDER BY n.source_id, p.created_at + "#, + ) + .bind(point_ids) + .fetch_all(pool) + .await?; + + let mut result: std::collections::HashMap> = + std::collections::HashMap::new(); + + for row in rows { + use sqlx::Row; + + let point_id: uuid::Uuid = row.get("point_id"); + let source_id: uuid::Uuid = row.get("source_id"); + + let info = PointSubscriptionInfo { + point_id, + external_id: row.get("external_id"), + scan_interval_s: row.get("scan_interval_s"), + }; + + result.entry(source_id).or_default().push(info); + } + + Ok(result) +} + +pub async fn get_points_with_ids( + pool: &PgPool, + source_id: uuid::Uuid, + point_ids: &[uuid::Uuid], +) -> Result, sqlx::Error> { + let rows = if point_ids.is_empty() { + sqlx::query( + r#" + SELECT + p.id as point_id, + n.external_id, + p.scan_interval_s + FROM point p + INNER JOIN node n ON p.node_id = n.id + WHERE n.source_id = $1 + ORDER BY p.created_at + "#, + ) + .bind(source_id) + .fetch_all(pool) + .await? + } else { + sqlx::query( + r#" + SELECT + p.id as point_id, + n.external_id, + p.scan_interval_s + FROM point p + INNER JOIN node n ON p.node_id = n.id + WHERE n.source_id = $1 + AND p.id = ANY($2) + ORDER BY p.created_at + "#, + ) + .bind(source_id) + .bind(point_ids) + .fetch_all(pool) + .await? + }; + + use sqlx::Row; + Ok(rows + .into_iter() + .map(|row| PointSubscriptionInfo { + point_id: row.get("point_id"), + external_id: row.get("external_id"), + scan_interval_s: row.get("scan_interval_s"), + }) + .collect()) +} + +// ==================== Tag 相关服务函数 ==================== + +/// 获取所有标签 +pub async fn get_all_tags( + pool: &PgPool, +) -> Result, sqlx::Error> { + query_as::<_, crate::model::Tag>( + r#"SELECT * FROM tag ORDER BY created_at"# + ) + .fetch_all(pool) + .await +} + +/// 根据ID获取标签 +pub async fn get_tag_by_id( + pool: &PgPool, + tag_id: uuid::Uuid, +) -> Result, sqlx::Error> { + query_as::<_, crate::model::Tag>( + r#"SELECT * FROM tag WHERE id = $1"# + ) + .bind(tag_id) + .fetch_optional(pool) + .await +} + +/// 获取标签下的点位 +pub async fn get_tag_points( + pool: &PgPool, + tag_id: uuid::Uuid, +) -> Result, sqlx::Error> { + query_as::<_, crate::model::Point>( + r#" + SELECT * + FROM point + WHERE tag_id = $1 + ORDER BY created_at + "# + ) + .bind(tag_id) + .fetch_all(pool) + .await +} + +/// 创建标签 +pub async fn create_tag( + pool: &PgPool, + name: &str, + description: Option<&str>, + point_ids: &[uuid::Uuid], +) -> Result { + let mut tx = pool.begin().await?; + + let tag_id = uuid::Uuid::new_v4(); + + sqlx::query( + r#" + INSERT INTO tag (id, name, description) + VALUES ($1, $2, $3) + "# + ) + .bind(tag_id) + .bind(name) + .bind(description) + .execute(&mut *tx) + .await?; + + if !point_ids.is_empty() { + for point_id in point_ids { + sqlx::query( + r#" + UPDATE point + SET tag_id = $1 + WHERE id = $2 + "# + ) + .bind(tag_id) + .bind(point_id) + .execute(&mut *tx) + .await?; + } + } + + tx.commit().await?; + + Ok(tag_id) +} + +/// 更新标签 +pub async fn update_tag( + pool: &PgPool, + tag_id: uuid::Uuid, + name: Option<&str>, + description: Option<&str>, + point_ids: Option<&[uuid::Uuid]>, +) -> Result<(), sqlx::Error> { + let mut tx = pool.begin().await?; + + // 更新基本信息 + if name.is_some() || description.is_some() { + let mut updates = Vec::new(); + let mut param_count = 1; + + if let Some(n) = name { + updates.push(format!("name = ${}", param_count)); + param_count += 1; + } + + if let Some(d) = description { + updates.push(format!("description = ${}", param_count)); + param_count += 1; + } + + updates.push("updated_at = NOW()".to_string()); + + let sql = format!( + r#"UPDATE tag SET {} WHERE id = ${}"#, + updates.join(", "), + param_count + ); + + let mut query = sqlx::query(&sql); + if let Some(n) = name { + query = query.bind(n); + } + if let Some(d) = description { + query = query.bind(d); + } + query = query.bind(tag_id); + + query.execute(&mut *tx).await?; + } + + // 更新点位列表 + if let Some(new_point_ids) = point_ids { + // 先将原属于该标签的点位移出标签 + sqlx::query( + r#" + UPDATE point + SET tag_id = NULL + WHERE tag_id = $1 + "# + ) + .bind(tag_id) + .execute(&mut *tx) + .await?; + + // 将新点位添加到标签 + for point_id in new_point_ids { + sqlx::query( + r#" + UPDATE point + SET tag_id = $1 + WHERE id = $2 + "# + ) + .bind(tag_id) + .bind(point_id) + .execute(&mut *tx) + .await?; + } + } + + tx.commit().await?; + + Ok(()) +} + +/// 删除标签 +pub async fn delete_tag( + pool: &PgPool, + tag_id: uuid::Uuid, +) -> Result { + let result = sqlx::query( + r#"DELETE FROM tag WHERE id = $1"# + ) + .bind(tag_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + diff --git a/src/telemetry.rs b/src/telemetry.rs new file mode 100644 index 0000000..79c1ddf --- /dev/null +++ b/src/telemetry.rs @@ -0,0 +1,154 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::model::ScanMode; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PointQuality { + Good, + Bad, + Uncertain, + Unknown, +} + +impl PointQuality { + pub fn from_status_code(status: &opcua::types::StatusCode) -> Self { + if status.is_good() { + Self::Good + } else if status.is_bad() { + Self::Bad + } else if status.is_uncertain() { + Self::Uncertain + } else { + Self::Unknown + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", content = "value", rename_all = "snake_case")] +pub enum DataValue { + Null, + Bool(bool), + Int(i64), + UInt(u64), + Float(f64), + Text(String), + Bytes(Vec), + Array(Vec), + Object(serde_json::Value), +} + +impl DataValue { + pub fn to_json_value(&self) -> serde_json::Value { + match self { + DataValue::Null => serde_json::Value::Null, + DataValue::Bool(v) => serde_json::Value::Bool(*v), + DataValue::Int(v) => serde_json::json!(*v), + DataValue::UInt(v) => serde_json::json!(*v), + DataValue::Float(v) => serde_json::json!(*v), + DataValue::Text(v) => serde_json::json!(v), + DataValue::Bytes(v) => serde_json::json!(v), + DataValue::Array(v) => { + serde_json::Value::Array(v.iter().map(DataValue::to_json_value).collect()) + } + DataValue::Object(v) => v.clone(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PointMonitorInfo { + pub protocol: String, + pub source_id: Uuid, + pub point_id: Uuid, + pub client_handle: u32, + pub scan_mode: ScanMode, + pub timestamp: Option>, + pub quality: PointQuality, + pub value: Option, + pub value_type: Option, + pub value_text: Option, +} + +impl PointMonitorInfo { + pub fn value_as_json(&self) -> Option { + self.value.as_ref().map(DataValue::to_json_value) + } +} + +#[derive(Debug, Clone)] +pub struct PointValueChangeEvent { + pub source_id: Uuid, + pub point_id: Option, + pub client_handle: u32, + pub value: Option, + pub value_type: Option, + pub value_text: Option, + pub quality: PointQuality, + pub protocol: String, + pub timestamp: Option>, + pub scan_mode: ScanMode, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WsPointMonitorInfo { + pub protocol: String, + pub point_id: Uuid, + pub scan_mode: String, + pub timestamp: Option, + pub quality: PointQuality, + pub value: Option, + pub value_type: Option, +} + +impl From<&PointMonitorInfo> for WsPointMonitorInfo { + fn from(m: &PointMonitorInfo) -> Self { + Self { + protocol: m.protocol.clone(), + point_id: m.point_id, + scan_mode: m.scan_mode.to_string(), + timestamp: m + .timestamp + .as_ref() + .map(crate::util::datetime::utc_to_local_string), + quality: m.quality.clone(), + value: m.value_as_json(), + value_type: m.value_type.clone(), + } + } +} + +pub fn opcua_variant_to_data(value: &opcua::types::Variant) -> DataValue { + use opcua::types::Variant; + + match value { + Variant::Empty => DataValue::Null, + Variant::Boolean(v) => DataValue::Bool(*v), + Variant::SByte(v) => DataValue::Int(*v as i64), + Variant::Byte(v) => DataValue::UInt(*v as u64), + Variant::Int16(v) => DataValue::Int(*v as i64), + Variant::UInt16(v) => DataValue::UInt(*v as u64), + Variant::Int32(v) => DataValue::Int(*v as i64), + Variant::UInt32(v) => DataValue::UInt(*v as u64), + Variant::Int64(v) => DataValue::Int(*v), + Variant::UInt64(v) => DataValue::UInt(*v), + Variant::Float(v) => DataValue::Float(*v as f64), + Variant::Double(v) => DataValue::Float(*v), + Variant::String(v) => DataValue::Text(v.to_string()), + Variant::ByteString(v) => DataValue::Bytes(v.value.clone().unwrap_or_default()), + Variant::Array(v) => { + DataValue::Array(v.values.iter().map(opcua_variant_to_data).collect()) + } + _ => DataValue::Text(value.to_string()), + } +} + +pub fn opcua_variant_type(value: &opcua::types::Variant) -> String { + match value.scalar_type_id() { + Some(t) => t.to_string(), + None => "unknown".to_string(), + } +} diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..e984aba --- /dev/null +++ b/src/util.rs @@ -0,0 +1,4 @@ +pub mod datetime; +pub mod log; +pub mod response; +pub mod validator; \ No newline at end of file diff --git a/src/util/datetime.rs b/src/util/datetime.rs new file mode 100644 index 0000000..20ec5ce --- /dev/null +++ b/src/util/datetime.rs @@ -0,0 +1,24 @@ +use chrono::{DateTime, Local, Utc}; +use serde::Serializer; + +pub fn utc_to_local_string(date: &DateTime) -> String { + date.with_timezone(&Local).format("%Y-%m-%d %H:%M:%S%.3f").to_string() +} + +pub fn utc_to_local_str(date: &DateTime, serializer: S) -> Result +where + S: Serializer, +{ + let formatted = utc_to_local_string(date); + serializer.serialize_str(&formatted) +} + +pub fn option_utc_to_local_str(date: &Option>, serializer: S) -> Result +where + S: Serializer, +{ + match date { + Some(d) => utc_to_local_str(d, serializer), + None => serializer.serialize_none(), + } +} diff --git a/src/util/log.rs b/src/util/log.rs new file mode 100644 index 0000000..9f21683 --- /dev/null +++ b/src/util/log.rs @@ -0,0 +1,36 @@ +use tracing_subscriber::{fmt, prelude::*, EnvFilter}; +use tracing_appender::{rolling, non_blocking}; +use std::sync::OnceLock; +use time::UtcOffset; + +static LOG_GUARD: OnceLock = OnceLock::new(); + +pub fn init_logger() { + std::fs::create_dir_all("./logs").ok(); + + let file_appender = rolling::daily("./logs", "app.log"); + let (file_writer, guard) = non_blocking(file_appender); + LOG_GUARD.set(guard).ok(); + + let timer = fmt::time::OffsetTime::new( + UtcOffset::from_hms(8, 0, 0).unwrap(), + time::format_description::well_known::Rfc3339, + ); + + tracing_subscriber::registry() + .with(EnvFilter::from_default_env()) + .with( + fmt::layer() + .compact() + .with_timer(timer.clone()) + .with_writer(std::io::stdout), + ) + .with( + fmt::layer() + .compact() + .with_timer(timer) + .with_writer(file_writer) + .with_ansi(false), + ) + .init(); +} diff --git a/src/util/response.rs b/src/util/response.rs new file mode 100644 index 0000000..8263e6e --- /dev/null +++ b/src/util/response.rs @@ -0,0 +1,91 @@ +use anyhow::Error; +use axum::{Json, http::StatusCode, response::IntoResponse}; +use serde::Serialize; +use serde_json::Value; +use sqlx::Error as SqlxError; + +#[derive(Debug, Serialize)] +pub struct ErrResp { + pub err_code: i32, + pub err_msg: String, + pub err_detail: Option, +} + +impl ErrResp { + pub fn new(err_code: i32, err_msg: impl Into, detail: Option) -> Self { + Self { + err_code, + err_msg: err_msg.into(), + err_detail: detail, + } + } +} + +#[derive(Debug)] +#[allow(dead_code)] +pub enum ApiErr { + Unauthorized(String, Option), + Forbidden(String, Option), + BadRequest(String, Option), + NotFound(String, Option), + Internal(String, Option), +} + +impl IntoResponse for ApiErr { + fn into_response(self) -> axum::response::Response { + match self { + ApiErr::Unauthorized(msg, detail) => { + (StatusCode::UNAUTHORIZED, Json(ErrResp::new(401, msg, detail))).into_response() + } + ApiErr::Forbidden(msg, detail) => { + (StatusCode::FORBIDDEN, Json(ErrResp::new(403, msg, detail))).into_response() + } + ApiErr::BadRequest(msg, detail) => { + (StatusCode::BAD_REQUEST, Json(ErrResp::new(400, msg, detail))).into_response() + } + ApiErr::NotFound(msg, detail) => { + (StatusCode::NOT_FOUND, Json(ErrResp::new(404, msg, detail))).into_response() + } + ApiErr::Internal(msg, detail) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrResp::new(500, msg, detail)), + ) + .into_response(), + } + } +} + +impl From for ApiErr { + fn from(err: Error) -> Self { + tracing::error!("Error: {:?}; root_cause: {}", err, err.root_cause()); + ApiErr::Internal( + err.to_string(), + Some(serde_json::json!({ + "root_cause": err.root_cause().to_string(), + "chain": err.chain().map(|e| e.to_string()).collect::>() + })), + ) + } +} + +impl From for ApiErr { + fn from(err: SqlxError) -> Self { + match err { + SqlxError::RowNotFound => { + ApiErr::NotFound("Resource not found".into(), None) + } + SqlxError::Database(db_err) => { + if db_err.code().as_deref() == Some("23505") { + ApiErr::BadRequest("数据已存在".into(), None) + } else { + tracing::error!("Database error: {}", db_err); + ApiErr::Internal("Database error".into(), None) + } + } + _ => { + tracing::error!("Database error: {}", err); + ApiErr::Internal("Database error".into(), None) + } + } + } +} diff --git a/src/util/validator.rs b/src/util/validator.rs new file mode 100644 index 0000000..2fe0337 --- /dev/null +++ b/src/util/validator.rs @@ -0,0 +1,34 @@ +use crate::util::response::ApiErr; +use serde_json::{json, Value}; +use validator::ValidationErrors; + +impl From for ApiErr { + fn from(errors: ValidationErrors) -> Self { + // 构建详细的错误信息 + let mut error_details = serde_json::Map::new(); + let mut first_error_msg = String::from("请求参数验证失败"); + + for (field, field_errors) in errors.field_errors() { + let error_list: Vec = field_errors + .iter() + .map(|e| { + e.message.as_ref() + .map(|m| m.to_string()) + .unwrap_or_else(|| e.code.to_string()) + }) + .collect(); + error_details.insert(field.to_string(), json!(error_list)); + + // 获取第一个字段的第一个错误信息 + if first_error_msg == "请求参数验证失败" && !error_list.is_empty() { + if let Some(msg) = field_errors[0].message.as_ref() { + first_error_msg = format!("{}: {}", field, msg); + } else { + first_error_msg = format!("{}: {}", field, field_errors[0].code); + } + } + } + + ApiErr::BadRequest(first_error_msg, Some(Value::Object(error_details))) + } +} diff --git a/src/websocket.rs b/src/websocket.rs new file mode 100644 index 0000000..00bac7f --- /dev/null +++ b/src/websocket.rs @@ -0,0 +1,269 @@ +use axum::{ + extract::{ + ws::{Message, WebSocket, WebSocketUpgrade}, + Path, State, + }, + response::IntoResponse, +}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::{broadcast, RwLock}; +use uuid::Uuid; + +/// WebSocket message payload types. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", content = "data")] +pub enum WsMessage { + PointValueChange(crate::telemetry::WsPointMonitorInfo), + PointSetValueBatchResult(crate::connection::BatchSetPointValueRes), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", content = "data", rename_all = "snake_case")] +pub enum WsClientMessage { + AuthWrite(WsAuthWriteReq), + PointSetValueBatch(crate::connection::BatchSetPointValueReq), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WsAuthWriteReq { + pub key: String, +} + +/// Room manager: room_id -> broadcast sender. +#[derive(Clone)] +pub struct RoomManager { + rooms: Arc>>>, +} + +impl RoomManager { + pub fn new() -> Self { + Self { + rooms: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Get or create room sender. + pub async fn get_or_create_room(&self, room_id: &str) -> broadcast::Sender { + let mut rooms = self.rooms.write().await; + + if let Some(sender) = rooms.get(room_id) { + return sender.clone(); + } + + let (sender, _) = broadcast::channel(100); + rooms.insert(room_id.to_string(), sender.clone()); + tracing::info!("Created new room: {}", room_id); + sender + } + + /// Get room sender if room exists. + pub async fn get_room(&self, room_id: &str) -> Option> { + let rooms = self.rooms.read().await; + rooms.get(room_id).cloned() + } + + /// Remove room if there are no receivers left. + pub async fn remove_room_if_empty(&self, room_id: &str) { + let mut rooms = self.rooms.write().await; + let should_remove = rooms + .get(room_id) + .map(|sender| sender.receiver_count() == 0) + .unwrap_or(false); + + if should_remove { + rooms.remove(room_id); + tracing::info!("Removed empty room: {}", room_id); + } + } + + /// Send message to room. + /// + /// Returns: + /// - Ok(n): n subscribers received it + /// - Ok(0): room missing or no active subscribers + pub async fn send_to_room(&self, room_id: &str, message: WsMessage) -> Result { + if let Some(sender) = self.get_room(room_id).await { + match sender.send(message) { + Ok(count) => Ok(count), + // No receiver is not exceptional in push scenarios. + Err(broadcast::error::SendError(_)) => Ok(0), + } + } else { + Ok(0) + } + } + +} + +impl Default for RoomManager { + fn default() -> Self { + Self::new() + } +} + +/// WebSocket manager. +#[derive(Clone)] +pub struct WebSocketManager { + public_room: Arc, +} + +impl WebSocketManager { + pub fn new() -> Self { + Self { + public_room: Arc::new(RoomManager::new()), + } + } + + /// Send message to public room. + pub async fn send_to_public(&self, message: WsMessage) -> Result { + self.public_room.get_or_create_room("public").await; + self.public_room.send_to_room("public", message).await + } + + /// Send message to a dedicated client room. + pub async fn send_to_client(&self, client_id: Uuid, message: WsMessage) -> Result { + self.public_room + .send_to_room(&client_id.to_string(), message) + .await + } +} + +impl Default for WebSocketManager { + fn default() -> Self { + Self::new() + } +} + +/// Public websocket handler. +pub async fn public_websocket_handler( + ws: WebSocketUpgrade, + State(state): State, +) -> impl IntoResponse { + let ws_manager = state.ws_manager.clone(); + let app_state = state.clone(); + ws.on_upgrade(move |socket| handle_socket(socket, ws_manager, "public".to_string(), app_state)) +} + +/// Client websocket handler. +pub async fn client_websocket_handler( + ws: WebSocketUpgrade, + Path(client_id): Path, + State(state): State, +) -> impl IntoResponse { + let ws_manager = state.ws_manager.clone(); + let room_id = client_id.to_string(); + let app_state = state.clone(); + ws.on_upgrade(move |socket| handle_socket(socket, ws_manager, room_id, app_state)) +} + +/// Handle websocket connection for one room. +async fn handle_socket( + mut socket: WebSocket, + ws_manager: Arc, + room_id: String, + state: crate::AppState, +) { + let room_sender = ws_manager.public_room.get_or_create_room(&room_id).await; + let mut rx = room_sender.subscribe(); + let mut can_write = false; + + loop { + tokio::select! { + maybe_msg = socket.recv() => { + match maybe_msg { + Some(Ok(msg)) => { + if matches!(msg, Message::Close(_)) { + break; + } + match msg { + Message::Text(text) => { + match serde_json::from_str::(&text) { + Ok(WsClientMessage::AuthWrite(payload)) => { + can_write = state.config.verify_write_key(&payload.key); + if !can_write { + tracing::warn!("WebSocket write auth failed in room {}", room_id); + } + } + Ok(WsClientMessage::PointSetValueBatch(payload)) => { + let response = if !can_write { + crate::connection::BatchSetPointValueRes { + success: false, + err_msg: Some("write permission denied".to_string()), + success_count: 0, + failed_count: 0, + results: vec![], + } + } else { + match state.connection_manager.write_point_values_batch(payload).await { + Ok(v) => v, + Err(e) => crate::connection::BatchSetPointValueRes { + success: false, + err_msg: Some(e), + success_count: 0, + failed_count: 1, + results: vec![crate::connection::SetPointValueResItem { + point_id: Uuid::nil(), + success: false, + err_msg: Some("Internal write error".to_string()), + }], + }, + } + }; + if let Err(e) = ws_manager + .public_room + .send_to_room(&room_id, WsMessage::PointSetValueBatchResult(response)) + .await + { + tracing::error!( + "Failed to send PointSetValueBatchResult to room {}: {}", + room_id, + e + ); + } + } + Err(e) => { + tracing::warn!( + "Invalid websocket message in room {}: {}", + room_id, + e + ); + } + } + } + _ => { + tracing::debug!("Received WebSocket message from room {}: {:?}", room_id, msg); + } + } + } + Some(Err(e)) => { + tracing::error!("WebSocket error in room {}: {}", room_id, e); + break; + } + None => break, + } + } + room_message = rx.recv() => { + match room_message { + Ok(message) => match serde_json::to_string(&message) { + Ok(json_str) => { + if socket.send(Message::Text(json_str.into())).await.is_err() { + break; + } + } + Err(e) => { + tracing::error!("Failed to serialize websocket message: {}", e); + } + }, + Err(broadcast::error::RecvError::Lagged(skipped)) => { + tracing::warn!("WebSocket room {} lagged, skipped {} messages", room_id, skipped); + } + Err(broadcast::error::RecvError::Closed) => break, + } + } + } + } + + ws_manager.public_room.remove_room_if_empty(&room_id).await; +}