diff --git a/Cargo.lock b/Cargo.lock index 2c5a3fa..948e104 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -175,6 +175,54 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d664a92ecae85fd0a7392615844904654d1d5f5514837f471ddef4a057aba1b6" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7079075b41f533b8c61d2a4d073c4676e1f8b249ff94a393b0595db304e0dd87" + +[[package]] +name = "anstyle-parse" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c75ac65da39e5fe5ab759307499ddad880d724eed2f6ce5b5e8a26f4f387928c" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e28923312444cdd728e4738b3f9c9cac739500909bb3d3c94b43551b16517648" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7" +dependencies = [ + "anstyle", + "windows-sys 0.52.0", +] + [[package]] name = "anyhow" version = "1.0.75" @@ -349,6 +397,26 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "audiopus" +version = "0.3.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab55eb0e56d7c6de3d59f544e5db122d7725ec33be6a276ee8241f3be6473955" +dependencies = [ + "audiopus_sys", +] + +[[package]] +name = "audiopus_sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62314a1546a2064e033665d658e88c620a62904be945f8147e6b16c3db9f8651" +dependencies = [ + "cmake", + "log", + "pkg-config", +] + [[package]] name = "autocfg" version = "1.1.0" @@ -1368,6 +1436,55 @@ dependencies = [ "libloading 0.7.4", ] +[[package]] +name = "clap" +version = "4.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfaff671f6b22ca62406885ece523383b9b64022e341e53e009a62ebc47a45f2" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a216b506622bb1d316cd51328dce24e07bdff4a6128a47c7e7fad11878d5adbb" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf9804afaaf59a91e75b022a30fb7229a7901f60c755489cc61c9b423b836442" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.41", +] + +[[package]] +name = "clap_lex" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "702fc72eb24e5a1e48ce58027a675bc24edd52096d5397d4aea7c6dd9eca0bd1" + +[[package]] +name = "cmake" +version = "0.1.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31c789563b815f77f4250caee12365734369f942439b7defd71e18a48197130" +dependencies = [ + "cc", +] + [[package]] name = "codespan-reporting" version = "0.11.1" @@ -1384,6 +1501,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colorchoice" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" + [[package]] name = "com-rs" version = "0.2.1" @@ -1570,6 +1693,41 @@ dependencies = [ "winapi", ] +[[package]] +name = "darling" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2c43f534ea4b0b049015d00269734195e6d3f0f6635cb692251aca6f9f8b3c" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e91455b86830a1c21799d94524df0845183fa55bafd9aa137b01c7d1065fa36" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 1.0.109", +] + +[[package]] +name = "darling_macro" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29b5acf0dea37a7f66f7b25d2c5e93fd46f8f6968b1a5d7a3e02e97768afc95a" +dependencies = [ + "darling_core", + "quote", + "syn 1.0.109", +] + [[package]] name = "dasp_sample" version = "0.11.0" @@ -1596,6 +1754,37 @@ dependencies = [ "tonic-build", ] +[[package]] +name = "derive_builder" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d13202debe11181040ae9063d739fa32cfcaaebe2275fe387703460ae2365b30" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e616858f6187ed828df7c64a6d71720d83767a7f19740b2d1b6fe6327b36e5" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_builder_macro" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58a94ace95092c5acb1e97a7e846b310cfbd499652f72297da7493f618a98d73" +dependencies = [ + "derive_builder_core", + "syn 1.0.109", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -1646,6 +1835,15 @@ dependencies = [ "syn 2.0.41", ] +[[package]] +name = "encoding_rs" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7268b386296a025e474d5140678f75d6de9493ae55a5d709eeb9dd08149945e1" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.1" @@ -1795,6 +1993,15 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures-channel" version = "0.3.29" @@ -1844,6 +2051,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "futures-macro" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b153fd91e4b0147f4aced87be237c98248656bb01050b96bf3ee89220a8ddb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.41", +] + [[package]] name = "futures-sink" version = "0.3.29" @@ -1863,9 +2081,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a19526d624e703a3179b3d322efec918b6246ea0fa51d41124525f00f1cc8104" dependencies = [ "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", + "slab", ] [[package]] @@ -2059,20 +2282,51 @@ name = "guardian" version = "0.1.0" dependencies = [ "bevy", + "clap", + "guardian_commands", "guardian_core", "tokio", ] +[[package]] +name = "guardian_commands" +version = "0.1.0" +dependencies = [ + "bevy", + "clap", + "nom", + "shlex", +] + [[package]] name = "guardian_core" version = "0.1.0" dependencies = [ "async-compat", + "audiopus", + "base64 0.21.5", "bevy", + "byteorder", + "bytes", "crossbeam-channel", "dcs-grpc", + "futures-core", + "futures-util", + "guardian_commands", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "shlex", + "simsearch", + "symspell", + "thiserror", "tokio", + "tokio-stream", + "tokio-util", "tonic", + "uuid", + "windows 0.52.0", ] [[package]] @@ -2243,6 +2497,35 @@ dependencies = [ "tokio-io-timeout", ] +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper", + "native-tls", + "tokio", + "tokio-native-tls", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + [[package]] name = "image" version = "0.24.7" @@ -2325,6 +2608,12 @@ dependencies = [ "mach2", ] +[[package]] +name = "ipnet" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" + [[package]] name = "itertools" version = "0.11.0" @@ -2564,6 +2853,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4192263c238a5f0d0c6bfd21f336a313a4ce1c450542449ca191bb657b4642ef" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -2639,6 +2938,24 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "native-tls" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e" +dependencies = [ + "lazy_static", + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndk" version = "0.7.0" @@ -2910,6 +3227,50 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +[[package]] +name = "openssl" +version = "0.10.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b8419dc8cc6d866deb801274bba2e6f8f6108c1bb7fcc10ee5ab864931dbb45" +dependencies = [ + "bitflags 2.4.1", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.41", +] + +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + +[[package]] +name = "openssl-sys" +version = "0.9.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3eaad34cdd97d81de97964fc7f29e2d104f483840d906ef56daa1912338460b" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "orbclient" version = "0.3.47" @@ -3313,6 +3674,45 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "216080ab382b992234dda86873c18d4c48358f5cfcb70fd693d7f6f2131b628b" +[[package]] +name = "reqwest" +version = "0.11.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b1ae8d9ac08420c66222fb9096fc5de435c3c48542bc5336c51892cffafb41" +dependencies = [ + "base64 0.21.5", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "hyper", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "mime_guess", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded", + "system-configuration", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", +] + [[package]] name = "rodio" version = "0.17.3" @@ -3392,12 +3792,44 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c3733bf4cf7ea0880754e19cb5a462007c4a8c1914bff372ccc95b464f1df88" +dependencies = [ + "windows-sys 0.48.0", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b64fb303737d99b81884b2c63433e9ae28abebe5eb5045dcdd175dc2ecf4de" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e932934257d3b408ed8f30db49d85ea163bfe74961f017f405b025af298f0c7a" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "serde" version = "1.0.193" @@ -3429,6 +3861,29 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_repr" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3081f5ffbb02284dda55132aa26daecedd7372a42417bbbab6f14ab7d6bb9145" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.41", +] + +[[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 = "sharded-slab" version = "0.1.7" @@ -3459,6 +3914,16 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +[[package]] +name = "simsearch" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1649c86f6ff4bec1c5a96b834f1e0da09eff92d85e3963286a61f41afca65de5" +dependencies = [ + "strsim", + "triple_accel", +] + [[package]] name = "slab" version = "0.4.9" @@ -3521,12 +3986,32 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + [[package]] name = "svg_fmt" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fb1df15f412ee2e9dfc1c504260fa695c1c3f10fe9f4a6ee2d2184d7d6450e2" +[[package]] +name = "symspell" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c90db29984243a6e6e914ffcb278db1a013eb0968b4df0ab19b1cc3ccf5094" +dependencies = [ + "derive_builder", + "serde", + "serde_derive", + "strsim", + "unidecode", + "wasm-bindgen", +] + [[package]] name = "syn" version = "1.0.109" @@ -3569,6 +4054,27 @@ dependencies = [ "winapi", ] +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "taffy" version = "0.3.18" @@ -3707,6 +4213,16 @@ dependencies = [ "syn 2.0.41", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.14" @@ -3716,6 +4232,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] @@ -3925,6 +4442,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "triple_accel" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622b09ce2fe2df4618636fb92176d205662f59803f39e70d1c333393082de96c" + [[package]] name = "try-lock" version = "0.2.5" @@ -3947,12 +4470,36 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "unicase" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89" +dependencies = [ + "version_check", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f2528f27a9eb2b21e69c95319b30bd0efd85d09c379741b0f78ea1d86be2416" + [[package]] name = "unicode-ident" version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +[[package]] +name = "unicode-normalization" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-width" version = "0.1.11" @@ -3965,6 +4512,29 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c" +[[package]] +name = "unidecode" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402bb19d8e03f1d1a7450e2bd613980869438e0666331be3e073089124aa1adc" + +[[package]] +name = "url" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "utf8parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" + [[package]] name = "uuid" version = "1.6.1" @@ -3981,6 +4551,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "vec_map" version = "0.8.2" @@ -4031,6 +4607,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ed0d4f68a3015cc185aff4db9506a015f4b96f95303897bfa23f846db54064e" dependencies = [ "cfg-if", + "serde", + "serde_json", "wasm-bindgen-macro", ] @@ -4566,6 +5144,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "x11-dl" version = "2.21.0" diff --git a/Cargo.toml b/Cargo.toml index b0eaa60..d75f30b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,12 @@ edition.workspace = true repository.workspace = true license.workspace = true +[workspace.lints.clippy] +type_complexity = "allow" + +[lints] +workspace = true + # Enable a small amount of optimization in debug mode [profile.dev] opt-level = 1 @@ -28,10 +34,18 @@ bevy = { version = "0.12", features = [ "multi-threaded", "trace", ] } -tokio = { version = "1.34", features = ["macros", "rt-multi-thread", "signal"] } dcs-grpc = { path = "./crates/dcs-grpc" } +guardian_commands = { path = "./crates/guardian_commands" } +guardian_core = { path = "./crates/guardian_core" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tokio = { version = "1.34", features = ["macros", "rt-multi-thread", "signal"] } +clap = { version = "4.4", features = ["derive"] } +shlex = "1.2" [dependencies] -guardian_core = { path = "./crates/guardian_core" } bevy.workspace = true +clap.workspace = true tokio.workspace = true +guardian_commands.workspace = true +guardian_core.workspace = true diff --git a/crates/dcs-grpc/Cargo.toml b/crates/dcs-grpc/Cargo.toml index 5971bff..990566c 100644 --- a/crates/dcs-grpc/Cargo.toml +++ b/crates/dcs-grpc/Cargo.toml @@ -12,9 +12,13 @@ bevy.workspace = true tonic = "0.10" prost = "0.12" prost-types = "0.12" -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" +serde.workspace = true +serde_json.workspace = true [build-dependencies] tonic-build = "0.10.2" protoc-bundled = { git = "https://github.com/rkusa/protoc-bundled.git", rev = "3.21.6" } + +[dev-dependencies] +serde.workspace = true +serde_json.workspace = true diff --git a/crates/dcs-grpc/protos/dcs/common/v0/common.proto b/crates/dcs-grpc/protos/dcs/common/v0/common.proto index c9e13f7..4a08a45 100644 --- a/crates/dcs-grpc/protos/dcs/common/v0/common.proto +++ b/crates/dcs-grpc/protos/dcs/common/v0/common.proto @@ -246,6 +246,8 @@ message Group { string name = 2; // The name of the group as assigned in the mission editor Coalition coalition = 3; // The coalition of the group GroupCategory category = 4; // The group category. + uint32 frequency = 5; + uint32 modulation = 6; } /** diff --git a/crates/guardian_commands/Cargo.toml b/crates/guardian_commands/Cargo.toml new file mode 100644 index 0000000..d1d5c2c --- /dev/null +++ b/crates/guardian_commands/Cargo.toml @@ -0,0 +1,19 @@ +# This packages is loosely(or not) based on https://github.com/RichoDemus/bevy-console + +[package] +name = "guardian_commands" +version.workspace = true +edition.workspace = true +repository.workspace = true +license.workspace = true + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +clap.workspace = true +bevy.workspace = true +shlex.workspace = true +nom = "7.1" + +[lints] +workspace = true diff --git a/crates/guardian_commands/src/call.rs b/crates/guardian_commands/src/call.rs new file mode 100644 index 0000000..4742c33 --- /dev/null +++ b/crates/guardian_commands/src/call.rs @@ -0,0 +1,336 @@ +use std::fmt::Display; + +use nom::branch::alt; +use nom::bytes::complete::{tag, tag_no_case}; +use nom::character::complete::{alpha1, alphanumeric1, char, multispace0}; +use nom::combinator::{map, opt, recognize}; +use nom::error::ErrorKind; +use nom::sequence::tuple; +use nom::{Err as NomError, IResult}; + +#[derive(Debug)] +pub enum Awacs { + Overlord, + Magic, + Wizard, + Focus, + Darkstar, +} + +#[derive(Debug)] +pub enum Receiver { + Awacs(Awacs), + Atc(String), //airfield name +} + +impl Display for Receiver { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Receiver::Awacs(Awacs::Overlord) => write!(f, "Overlord"), + Receiver::Awacs(Awacs::Magic) => write!(f, "Magic"), + Receiver::Awacs(Awacs::Wizard) => write!(f, "Wizard"), + Receiver::Awacs(Awacs::Focus) => write!(f, "Focus"), + Receiver::Awacs(Awacs::Darkstar) => write!(f, "Darkstar"), + Receiver::Atc(airfield) => write!(f, "{}", airfield.to_owned()), + } + } +} + +// impl ToString for Receiver { +// fn to_string(&self) -> String { +// match self { +// Receiver::Awacs(Awacs::Overlord) => "Overlord".to_string(), +// Receiver::Awacs(Awacs::Darkstar) => "Darkstar".to_string(), +// Receiver::Atc(airfield) => airfield.to_owned(), +// } +// } +// } + +fn parse_receiver(input: &str) -> IResult<&str, Receiver> { + alt(( + map(tag_no_case("overlord"), |_| { + Receiver::Awacs(Awacs::Overlord) + }), + map(tag_no_case("magic"), |_| Receiver::Awacs(Awacs::Magic)), + map(tag_no_case("wizard"), |_| Receiver::Awacs(Awacs::Wizard)), + map(tag_no_case("focus"), |_| Receiver::Awacs(Awacs::Focus)), + map(tag_no_case("darkstar"), |_| { + Receiver::Awacs(Awacs::Darkstar) + }), + map(alphanumeric1, |e: &str| Receiver::Atc(e.to_owned())), // anything else = airfield? airfields _can_ contain spaces though.. soo w... hmm... parsing that is a bitch + ))(input) +} + +#[derive(Debug)] +pub enum Squadron { + Enfield, + Springfield, + Uzi, + Colt, + Dodge, + Ford, + Chevy, + Pontiac, + Viper, + Venom, + Lobo, + Cowboy, + Python, + Rattler, + Panther, + Wolf, + Weasel, + Wild, + Ninja, + Jedi, +} + +impl ToString for Squadron { + fn to_string(&self) -> String { + match self { + Squadron::Enfield => "Enfield".to_string(), + Squadron::Springfield => "Springfield".to_string(), + Squadron::Uzi => "Uzi".to_string(), + Squadron::Colt => "Colt".to_string(), + Squadron::Dodge => "Dodge".to_string(), + Squadron::Ford => "Ford".to_string(), + Squadron::Chevy => "Chevy".to_string(), + Squadron::Pontiac => "Pontiac".to_string(), + Squadron::Viper => "Viper".to_string(), + Squadron::Venom => "Venom".to_string(), + Squadron::Lobo => "Lobo".to_string(), + Squadron::Cowboy => "Cowboy".to_string(), + Squadron::Python => "Python".to_string(), + Squadron::Rattler => "Rattler".to_string(), + Squadron::Panther => "Panther".to_string(), + Squadron::Wolf => "Wolf".to_string(), + Squadron::Weasel => "Weasel".to_string(), + Squadron::Wild => "Wild".to_string(), + Squadron::Ninja => "Ninja".to_string(), + Squadron::Jedi => "Jedi".to_string(), + } + } +} + +impl std::str::FromStr for Squadron { + type Err = Box; + + fn from_str(s: &str) -> Result { + match s { + "Enfield" => Ok(Squadron::Enfield), + "Springfield" => Ok(Squadron::Springfield), + "Uzi" => Ok(Squadron::Uzi), + "Colt" => Ok(Squadron::Colt), + "Dodge" => Ok(Squadron::Dodge), + "Ford" => Ok(Squadron::Ford), + "Chevy" => Ok(Squadron::Chevy), + "Pontiac" => Ok(Squadron::Pontiac), + "Viper" => Ok(Squadron::Viper), + "Venom" => Ok(Squadron::Venom), + "Lobo" => Ok(Squadron::Lobo), + "Cowboy" => Ok(Squadron::Cowboy), + "Python" => Ok(Squadron::Python), + "Rattler" => Ok(Squadron::Rattler), + "Panther" => Ok(Squadron::Panther), + "Wolf" => Ok(Squadron::Wolf), + "Weasel" => Ok(Squadron::Weasel), + "Wild" => Ok(Squadron::Wild), + "Ninja" => Ok(Squadron::Ninja), + "Jedi" => Ok(Squadron::Jedi), + _ => Err("Invalid squadron".into()), + } + } +} + +#[derive(Debug)] +pub struct Element { + pub squadron: Squadron, + pub group: u8, + pub unit: u8, +} + +impl Display for Element { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{} {} {}", + self.squadron.to_string(), + self.group, + self.unit + ) + } +} + +fn add_spaces_around_numbers(input: &str) -> String { + let mut result = String::new(); + let mut chars = input.chars().peekable(); + + while let Some(c) = chars.next() { + result.push(c); + + if let Some(next_char) = chars.peek() { + if next_char.is_numeric() { + result.push(' '); + } + } + } + + result +} + +impl std::str::FromStr for Element { + type Err = Box; + + fn from_str(s: &str) -> Result { + let d = add_spaces_around_numbers(s); + let mut d = d.split(' '); + let squadron: Squadron = d.next().unwrap().parse()?; + let group: u8 = d.next().unwrap().parse()?; + let unit: u8 = d.next().unwrap().parse()?; + + Ok(Element { + squadron, + group, + unit, + }) + } +} + +fn parse_element(input: &str) -> IResult<&str, Element> { + let mut parser = tuple(( + alpha1, // Squadron name + opt(multispace0), // Optional whitespace + opt(tuple(( + // Optional group + opt(multispace0), + parse_group_unit, + opt(multispace0), + ))), + opt(char('-')), + opt(tuple(( + // Optional unit + opt(multispace0), + parse_group_unit, + opt(multispace0), + ))), + )); + + let (remainder, (squadron_str, _, group_str, _, unit_str)) = parser(input)?; + + let squadron = match squadron_str.to_lowercase().as_str() { + "enfield" => Squadron::Enfield, + "springfield" => Squadron::Springfield, + "uzi" => Squadron::Uzi, + "colt" => Squadron::Colt, + "dodge" => Squadron::Dodge, + "ford" => Squadron::Ford, + "chevy" => Squadron::Chevy, + "pontiac" => Squadron::Pontiac, + "viper" => Squadron::Viper, + "venom" => Squadron::Venom, + "lobo" => Squadron::Lobo, + "cowboy" => Squadron::Cowboy, + "python" => Squadron::Python, + "rattler" => Squadron::Rattler, + "panther" => Squadron::Panther, + "wolf" => Squadron::Wolf, + "weasel" => Squadron::Weasel, + "wild" => Squadron::Wild, + "ninja" => Squadron::Ninja, + "jedi" => Squadron::Jedi, + _ => { + return Err(NomError::Error(nom::error::Error { + input: squadron_str, + code: ErrorKind::Alpha, + })) + } + }; + + let group = group_str.unwrap_or((None, 0, None)).1; + let unit = unit_str.unwrap_or((None, 0, None)).1; + + Ok(( + remainder, + Element { + squadron, + group, + unit, + }, + )) +} + +fn parse_group_unit(input: &str) -> IResult<&str, u8> { + let parse_digit = map( + recognize(alt(( + char('0'), + char('1'), + char('2'), + char('3'), + char('4'), + char('5'), + char('6'), + char('7'), + char('8'), + char('9'), + ))), + |s: &str| s.parse::().unwrap(), + ); + + let parse_word = alt(( + map(tag("one"), |_| 1), + map(tag("two"), |_| 2), + map(tag("three"), |_| 3), + map(tag("four"), |_| 4), + map(tag("five"), |_| 5), + map(tag("six"), |_| 6), + map(tag("seven"), |_| 7), + map(tag("eight"), |_| 8), + map(tag("nine"), |_| 9), + )); + + alt((parse_digit, parse_word))(input) +} + +// #[derive(Debug)] +// pub enum Unit { +// NauticalMiles, +// Kilometers, +// } + +// #[derive(Debug)] +// pub enum Call { +// RadioCheck(Awacs, Element), +// Shopping(Awacs, Element), +// LocateAirfield(Awacs, Element, String), +// LocateFriendly(Awacs, Element, Element), +// Tripwire(Awacs, Element, Option, Unit), +// } + +#[derive(Debug)] +pub struct Call { + pub receiver: Receiver, + pub element: Element, + pub command: String, +} + +pub fn parse_call(input: &str) -> IResult<&str, Call> { + let mut parser = tuple((parse_receiver, multispace0, parse_element)); + + let (command, (receiver, _, element)) = parser(input)?; + + if element.group == 0 || element.unit == 0 { + return Err(NomError::Error(nom::error::Error { + input: command, + code: ErrorKind::Digit, + })); + } + + Ok(( + "", + Call { + receiver, + element, + command: command.to_string(), + }, + )) +} diff --git a/crates/guardian_commands/src/console.rs b/crates/guardian_commands/src/console.rs new file mode 100644 index 0000000..f5fde72 --- /dev/null +++ b/crates/guardian_commands/src/console.rs @@ -0,0 +1,554 @@ +use bevy::{ + app::{App, Startup, Update}, + ecs::{ + component::Tick, + entity::Entity, + event::{Event, EventReader}, + schedule::IntoSystemConfigs, + system::{ResMut, Resource, SystemMeta, SystemParam}, + world::{unsafe_world_cell::UnsafeWorldCell, World}, + }, + log::{debug, warn}, +}; +// use bevy::{input::keyboard::KeyboardInput, prelude::*}; +// use bevy_egui::egui::{self, Align, ScrollArea, TextEdit}; +// use bevy_egui::egui::{text::LayoutJob, text_edit::CCursorRange}; +// use bevy_egui::egui::{Context, Id}; +// use bevy_egui::{ +// egui::{epaint::text::cursor::CCursor, Color32, FontId, TextFormat}, +// EguiContexts, +// }; +use clap::{builder::StyledStr, CommandFactory, FromArgMatches}; +use std::collections::BTreeMap; +use std::marker::PhantomData; +use std::mem; + +use crate::{parser, ConsoleSet}; + +type ConsoleCommandEnteredReaderSystemParam = EventReader<'static, 'static, ConsoleCommandEntered>; + +/// A super-trait for command like structures +pub trait Command: NamedCommand + CommandFactory + FromArgMatches + Sized + Resource {} +impl Command for T {} + +/// Trait used to allow uniquely identifying commands at compile time +pub trait NamedCommand { + /// Return the unique command identifier (same as the command "executable") + fn name() -> &'static str; +} + +/// Executed parsed console command. +/// +/// Used to capture console commands which implement [`CommandName`], [`CommandArgs`] & [`CommandHelp`]. +/// These can be easily implemented with the [`ConsoleCommand`](bevy_console_derive::ConsoleCommand) derive macro. +/// +/// # Example +/// +/// ``` +/// # use bevy_console::ConsoleCommand; +/// # use clap::Parser; +/// /// Prints given arguments to the console. +/// #[derive(Parser, ConsoleCommand)] +/// #[command(name = "log")] +/// struct LogCommand { +/// /// Message to print +/// msg: String, +/// /// Number of times to print message +/// num: Option, +/// } +/// +/// fn log_command(mut log: ConsoleCommand) { +/// if let Some(Ok(LogCommand { msg, num })) = log.take() { +/// log.ok(); +/// } +/// } +/// ``` +pub struct ConsoleCommand { + command: Option>, + pub raw: Option, + pub pilot: Option, + pub operator: Option, +} + +impl ConsoleCommand { + /// Returns Some(T) if the command was executed and arguments were valid. + /// + /// This method should only be called once. + /// Consecutive calls will return None regardless if the command occurred. + pub fn take(&mut self) -> Option> { + mem::take(&mut self.command) + } +} + +pub struct ConsoleCommandState { + #[allow(clippy::type_complexity)] + event_reader: ::State, + marker: PhantomData, +} + +unsafe impl SystemParam for ConsoleCommand { + type State = ConsoleCommandState; + type Item<'w, 's> = ConsoleCommand; + + fn init_state(world: &mut World, system_meta: &mut SystemMeta) -> Self::State { + let event_reader = ConsoleCommandEnteredReaderSystemParam::init_state(world, system_meta); + ConsoleCommandState { + event_reader, + marker: PhantomData, + } + } + + #[inline] + unsafe fn get_param<'w, 's>( + state: &'s mut Self::State, + system_meta: &SystemMeta, + world: UnsafeWorldCell<'w>, + change_tick: Tick, + ) -> Self::Item<'w, 's> { + let mut event_reader = ConsoleCommandEnteredReaderSystemParam::get_param( + &mut state.event_reader, + system_meta, + world, + change_tick, + ); + + let command = event_reader.read().find_map(|command| { + for name in parser::parse(T::name()) { + if name == command.command_name { + let clap_command = T::command().no_binary_name(true); + let arg_matches = clap_command.try_get_matches_from(command.args.iter()); + + debug!( + "Trying to parse as `{}`. Result: {arg_matches:?}", + command.command_name + ); + + match arg_matches { + Ok(matches) => match T::from_arg_matches(&matches) { + Ok(from_arg_matches) => { + return Some(Ok(( + from_arg_matches, + command.raw.clone(), + command.pilot, + command.operator, + ))); + } + Err(err) => return Some(Err(err)), + }, + Err(err) => { + return Some(Err(err)); + } + } + } + } + + None + }); + + if let Some(Ok(command)) = command { + return ConsoleCommand { + command: Some(Ok(command.0)), + raw: Some(command.1), + pilot: Some(command.2), + operator: Some(command.3), + }; + } + + ConsoleCommand { + command: None, + raw: None, + pilot: None, + operator: None, + } + } +} +/// Parsed raw console command into `command` and `args`. +#[derive(Clone, Debug, Event)] +pub struct ConsoleCommandEntered { + /// the command definition + pub command_name: String, + /// the raw input string + pub raw: String, + /// Raw parsed arguments + pub args: Vec, + /// Pilot making the request + pub pilot: Entity, + /// Operator responding + pub operator: Entity, +} + +/// Events to print to the console. +#[derive(Clone, Debug, Eq, Event, PartialEq)] +pub struct PrintConsoleLine { + /// Console line + pub line: StyledStr, +} + +impl PrintConsoleLine { + /// Creates a new console line to print. + pub const fn new(line: StyledStr) -> Self { + Self { line } + } +} + +/// Console configuration +#[derive(Clone, Resource, Default)] +pub struct ConsoleConfiguration { + /// Registered console commands + pub commands: BTreeMap, +} + +/// Add a console commands to Bevy app. +pub trait AddConsoleCommand { + /// Add a console command with a given system. + /// + /// This registers the console command so it will print with the built-in `help` console command. + /// + /// # Example + /// + /// ``` + /// # use bevy::prelude::*; + /// # use bevy_console::{AddConsoleCommand, ConsoleCommand}; + /// # use clap::Parser; + /// App::new() + /// .add_console_command::(log_command); + /// # + /// # /// Prints given arguments to the console. + /// # #[derive(Parser, ConsoleCommand)] + /// # #[command(name = "log")] + /// # struct LogCommand; + /// # + /// # fn log_command(mut log: ConsoleCommand) {} + /// ``` + fn add_console_command( + &mut self, + system: impl IntoSystemConfigs, + ) -> &mut Self; +} + +impl AddConsoleCommand for App { + fn add_console_command( + &mut self, + system: impl IntoSystemConfigs, + ) -> &mut Self { + let sys = move |mut config: ResMut| { + for name in crate::parser::parse(T::name()) { + let command = T::command().no_binary_name(true); + if config.commands.contains_key(&name) { + warn!( + "console command '{}' already registered and was overwritten", + name + ); + } + config.commands.insert(name.clone(), command); + } + }; + + self.add_systems(Startup, sys) + .add_systems(Update, system.in_set(ConsoleSet::Commands)) + } +} + +// /// Console open state +// #[derive(Default, Resource)] +// pub struct ConsoleOpen { +// /// Console open +// pub open: bool, +// } + +// #[derive(Resource)] +// pub(crate) struct ConsoleState { +// pub(crate) buf: String, +// pub(crate) scrollback: Vec, +// pub(crate) history: VecDeque, +// pub(crate) history_index: usize, +// } + +// impl Default for ConsoleState { +// fn default() -> Self { +// ConsoleState { +// buf: String::default(), +// scrollback: Vec::new(), +// history: VecDeque::from([StyledStr::new()]), +// history_index: 0, +// } +// } +// } + +// pub(crate) fn console_ui( +// mut egui_context: EguiContexts, +// config: Res, +// mut keyboard_input_events: EventReader, +// keys: Res>, +// mut state: ResMut, +// mut command_entered: EventWriter, +// mut console_open: ResMut, +// ) { +// let keyboard_input_events = keyboard_input_events.iter().collect::>(); +// let ctx = egui_context.ctx_mut(); + +// let pressed = keyboard_input_events +// .iter() +// .any(|code| console_key_pressed(code, &config.keys)); + +// // always close if console open +// // avoid opening console if typing in another text input +// if pressed && (console_open.open || !ctx.wants_keyboard_input()) { +// console_open.open = !console_open.open; +// } + +// if console_open.open { +// egui::Window::new("Console") +// .collapsible(false) +// .default_pos([config.left_pos, config.top_pos]) +// .default_size([config.width, config.height]) +// .resizable(true) +// .show(ctx, |ui| { +// ui.vertical(|ui| { +// let scroll_height = ui.available_height() - 30.0; + +// // Scroll area +// ScrollArea::vertical() +// .auto_shrink([false, false]) +// .stick_to_bottom(true) +// .max_height(scroll_height) +// .show(ui, |ui| { +// ui.vertical(|ui| { +// for line in &state.scrollback { +// let mut text = LayoutJob::default(); + +// text.append( +// &line.to_string(), //TOOD: once clap supports custom styling use it here +// 0f32, +// TextFormat::simple(FontId::monospace(14f32), Color32::GRAY), +// ); + +// ui.label(text); +// } +// }); + +// // Scroll to bottom if console just opened +// if console_open.is_changed() { +// ui.scroll_to_cursor(Some(Align::BOTTOM)); +// } +// }); + +// // Separator +// ui.separator(); + +// // Input +// let text_edit = TextEdit::singleline(&mut state.buf) +// .desired_width(f32::INFINITY) +// .lock_focus(true) +// .font(egui::TextStyle::Monospace); + +// // Handle enter +// let text_edit_response = ui.add(text_edit); +// if text_edit_response.lost_focus() +// && ui.input(|i| i.key_pressed(egui::Key::Enter)) +// { +// if state.buf.trim().is_empty() { +// state.scrollback.push(StyledStr::new()); +// } else { +// let msg = format!("{}{}", config.symbol, state.buf); +// state.scrollback.push(msg.into()); +// let cmd_string = state.buf.clone(); +// state.history.insert(1, cmd_string.into()); +// if state.history.len() > config.history_size + 1 { +// state.history.pop_back(); +// } + +// let mut args = Shlex::new(&state.buf).collect::>(); + +// if !args.is_empty() { +// let command_name = args.remove(0); +// debug!("Command entered: `{command_name}`, with args: `{args:?}`"); + +// let command = config.commands.get(command_name.as_str()); + +// if command.is_some() { +// command_entered +// .send(ConsoleCommandEntered { command_name, args }); +// } else { +// debug!( +// "Command not recognized, recognized commands: `{:?}`", +// config.commands.keys().collect::>() +// ); + +// state.scrollback.push("error: Invalid command".into()); +// } +// } + +// state.buf.clear(); +// } +// } + +// // Clear on ctrl+l +// if keyboard_input_events +// .iter() +// .any(|&k| k.state.is_pressed() && k.key_code == Some(KeyCode::L)) +// && (keys.any_pressed([KeyCode::ControlLeft, KeyCode::ControlRight])) +// { +// state.scrollback.clear(); +// } + +// // Handle up and down through history +// if text_edit_response.has_focus() +// && ui.input(|i| i.key_pressed(egui::Key::ArrowUp)) +// && state.history.len() > 1 +// && state.history_index < state.history.len() - 1 +// { +// if state.history_index == 0 && !state.buf.trim().is_empty() { +// *state.history.get_mut(0).unwrap() = state.buf.clone().into(); +// } + +// state.history_index += 1; +// let previous_item = state.history.get(state.history_index).unwrap().clone(); +// state.buf = previous_item.to_string(); + +// set_cursor_pos(ui.ctx(), text_edit_response.id, state.buf.len()); +// } else if text_edit_response.has_focus() +// && ui.input(|i| i.key_pressed(egui::Key::ArrowDown)) +// && state.history_index > 0 +// { +// state.history_index -= 1; +// let next_item = state.history.get(state.history_index).unwrap().clone(); +// state.buf = next_item.to_string(); + +// set_cursor_pos(ui.ctx(), text_edit_response.id, state.buf.len()); +// } + +// // Focus on input +// ui.memory_mut(|m| m.request_focus(text_edit_response.id)); +// }); +// }); +// } +// } + +// pub(crate) fn receive_console_line( +// mut console_state: ResMut, +// mut events: EventReader, +// ) { +// for event in events.iter() { +// let event: &PrintConsoleLine = event; +// console_state.scrollback.push(event.line.clone()); +// } +// } + +// fn console_key_pressed( +// keyboard_input: &KeyboardInput, +// configured_keys: &[ToggleConsoleKey], +// ) -> bool { +// if !keyboard_input.state.is_pressed() { +// return false; +// } + +// for configured_key in configured_keys { +// match configured_key { +// ToggleConsoleKey::KeyCode(configured_key_code) => match keyboard_input.key_code { +// None => continue, +// Some(pressed_key) => { +// if configured_key_code == &pressed_key { +// return true; +// } +// } +// }, +// ToggleConsoleKey::ScanCode(configured_scan_code) => { +// if &keyboard_input.scan_code == configured_scan_code { +// return true; +// } +// } +// } +// } + +// false +// } + +// fn set_cursor_pos(ctx: &Context, id: Id, pos: usize) { +// if let Some(mut state) = TextEdit::load_state(ctx, id) { +// state.set_ccursor_range(Some(CCursorRange::one(CCursor::new(pos)))); +// state.store(ctx, id); +// } +// } + +// #[cfg(test)] +// mod tests { +// use bevy::input::ButtonState; + +// use super::*; + +// #[test] +// fn test_console_key_pressed_scan_code() { +// let input = KeyboardInput { +// scan_code: 41, +// key_code: None, +// state: ButtonState::Pressed, +// window: Entity::PLACEHOLDER, +// }; + +// let config = vec![ToggleConsoleKey::ScanCode(41)]; + +// let result = console_key_pressed(&input, &config); +// assert!(result); +// } + +// #[test] +// fn test_console_wrong_key_pressed_scan_code() { +// let input = KeyboardInput { +// scan_code: 42, +// key_code: None, +// state: ButtonState::Pressed, +// window: Entity::PLACEHOLDER, +// }; + +// let config = vec![ToggleConsoleKey::ScanCode(41)]; + +// let result = console_key_pressed(&input, &config); +// assert!(!result); +// } + +// #[test] +// fn test_console_key_pressed_key_code() { +// let input = KeyboardInput { +// scan_code: 0, +// key_code: Some(KeyCode::Grave), +// state: ButtonState::Pressed, +// window: Entity::PLACEHOLDER, +// }; + +// let config = vec![ToggleConsoleKey::KeyCode(KeyCode::Grave)]; + +// let result = console_key_pressed(&input, &config); +// assert!(result); +// } + +// #[test] +// fn test_console_wrong_key_pressed_key_code() { +// let input = KeyboardInput { +// scan_code: 0, +// key_code: Some(KeyCode::A), +// state: ButtonState::Pressed, +// window: Entity::PLACEHOLDER, +// }; + +// let config = vec![ToggleConsoleKey::KeyCode(KeyCode::Grave)]; + +// let result = console_key_pressed(&input, &config); +// assert!(!result); +// } + +// #[test] +// fn test_console_key_right_key_but_not_pressed() { +// let input = KeyboardInput { +// scan_code: 0, +// key_code: Some(KeyCode::Grave), +// state: ButtonState::Released, +// window: Entity::PLACEHOLDER, +// }; + +// let config = vec![ToggleConsoleKey::KeyCode(KeyCode::Grave)]; + +// let result = console_key_pressed(&input, &config); +// assert!(!result); +// } +// } diff --git a/crates/guardian_commands/src/lib.rs b/crates/guardian_commands/src/lib.rs new file mode 100644 index 0000000..daccbbd --- /dev/null +++ b/crates/guardian_commands/src/lib.rs @@ -0,0 +1,62 @@ +pub mod call; + +use bevy::prelude::*; +// pub use bevy_console_derive::ConsoleCommand; +// use bevy_egui::EguiPlugin; + +// use crate::commands::clear::{clear_command, ClearCommand}; +// use crate::commands::exit::{exit_command, ExitCommand}; +// use crate::commands::help::{help_command, HelpCommand}; +pub use crate::console::{ + AddConsoleCommand, Command, ConsoleCommand, ConsoleCommandEntered, ConsoleConfiguration, + NamedCommand, PrintConsoleLine, +}; +// pub use color::{Style, StyledStr}; + +// use crate::console::ConsoleState; + +// mod color; +// mod commands; +mod console; +mod parser; +// mod macros; + +/// Console plugin. +pub struct CommandsPlugin; + +#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)] +/// The SystemSet for console/command related systems +pub enum ConsoleSet { + /// Systems operating the console UI (the input layer) + ConsoleIO, + + /// Systems executing console commands (the functionality layer). + /// All command handler systems are added to this set + Commands, + + /// Systems running after command systems, which depend on the fact commands have executed beforehand (the output layer). + /// For example a system which makes use of [`PrintConsoleLine`] events should be placed in this set to be able to receive + /// New lines to print in the same frame + PostCommands, +} + +/// Run condition which does not run any command systems if no command was entered +fn have_commands(commands: EventReader) -> bool { + !commands.is_empty() +} + +impl Plugin for CommandsPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .add_event::() + .configure_sets( + Update, + ( + ConsoleSet::Commands + .after(ConsoleSet::ConsoleIO) + .run_if(have_commands), + ConsoleSet::PostCommands.after(ConsoleSet::Commands), + ), + ); + } +} diff --git a/crates/guardian_commands/src/parser.rs b/crates/guardian_commands/src/parser.rs new file mode 100644 index 0000000..98d17fc --- /dev/null +++ b/crates/guardian_commands/src/parser.rs @@ -0,0 +1,194 @@ +use nom::{ + bytes::complete::tag, + error::{Error, ErrorKind, ParseError}, + sequence::delimited, + IResult, +}; + +// from https://github.com/getreu/parse-hyperlinks/blob/5af034d14aa72ffb9e705da13bf557a564b1bebf/parse-hyperlinks/src/lib.rs#L41 +fn take_until_unbalanced( + opening_bracket: char, + closing_bracket: char, +) -> impl Fn(&str) -> IResult<&str, &str> { + move |i: &str| { + let mut index = 0; + let mut bracket_counter = 0; + while let Some(n) = &i[index..].find(&[opening_bracket, closing_bracket, '\\'][..]) { + index += n; + let mut it = i[index..].chars(); + match it.next() { + Some('\\') => { + // Skip the escape char `\`. + index += '\\'.len_utf8(); + // Skip also the following char. + if let Some(c) = it.next() { + index += c.len_utf8(); + } + } + Some(c) if c == opening_bracket => { + bracket_counter += 1; + index += opening_bracket.len_utf8(); + } + Some(c) if c == closing_bracket => { + // Closing bracket. + bracket_counter -= 1; + index += closing_bracket.len_utf8(); + } + // Can not happen. + _ => unreachable!(), + }; + // We found the unmatched closing bracket. + if bracket_counter == -1 { + // We do not consume it. + index -= closing_bracket.len_utf8(); + return Ok((&i[index..], &i[0..index])); + }; + } + + if bracket_counter == 0 { + Ok(("", i)) + } else { + Err(nom::Err::Error(Error::from_error_kind( + i, + ErrorKind::TakeUntil, + ))) + } + } +} + +fn split(input: &str) -> IResult<&str, &str> { + let mut parser = delimited(tag("["), take_until_unbalanced('[', ']'), tag("]")); + parser(input) +} + +fn inner_parse(input: &str, depth: usize) -> Vec { + let mut output = vec![]; + let start_idx = input.find('[').unwrap_or(input.len()); + + if start_idx > 0 && depth == 0 { + output.push(input[..start_idx].to_string()); + } + + if start_idx == input.len() { + return output; + } + + let input = &input[start_idx..]; + + let splitted = split(input).unwrap(); + + let variants: Vec = splitted + .1 + .split('|') + .map(|v| v.to_string()) + .collect::>(); + + let mut temp = vec![]; + let mut temp_str = String::new(); + for v in &variants { + if v.contains('[') || v.contains(']') { + temp_str.push_str(v); + temp_str.push('|'); + } else { + let len = temp_str.len(); + if len > 0 { + temp.push(temp_str[..len - 1].to_string()); + temp.push(v.to_string()); + } + temp_str.clear(); + } + } + + let variants = if temp.is_empty() { variants } else { temp }; + + // check if variants need expanding + let mut temp = vec![]; + for v in variants { + if v.contains('[') { + let index = v.find('[').unwrap_or(0); + let prepend = &v[..index]; + + let remainder = inner_parse(&v, depth + 1); + for r in &remainder { + let l = format!("{}{}", prepend, r); + temp.push(l); + } + } else { + temp.push(v); + } + } + + let remainder = splitted.0; + let backup = output.clone(); + output.clear(); + + for variant in temp { + if remainder.contains('[') { + let index = remainder.find('[').unwrap_or(0); + let prepend = &remainder[..index]; + + let r2 = inner_parse(remainder, depth + 1); + + if !backup.is_empty() { + for o in &backup { + for r in &r2 { + let l = format!("{}{}{}{}", o, variant, prepend, r); + output.push(l); + } + } + } else { + for r in &r2 { + let l = format!("{}{}{}", variant, prepend, r); + output.push(l); + } + } + } else if !backup.is_empty() { + for o in &backup { + output.push(format!("{}{}{}", o, variant, remainder)); + } + } else { + output.push(format!("{}{}", variant, remainder)); + } + } + + output +} + +pub fn parse(input: &str) -> Vec { + inner_parse(input, 0) +} + +#[test] +fn parse_test() { + // This works + assert_eq!( + parse("[Hello|Goodbye], World!"), + vec!["Hello, World!", "Goodbye, World!"] + ); + + assert_eq!( + parse("[Hello|Goodbye], [World|Moon]!"), + vec![ + "Hello, World!", + "Hello, Moon!", + "Goodbye, World!", + "Goodbye, Moon!", + ] + ); + + assert_eq!( + parse("[[Heading|Bearing] [to|for]|Where is] that"), + vec![ + "Heading to that", + "Heading for that", + "Bearing to that", + "Bearing for that", + "Where is that", + ] + ); + + assert_eq!( + parse("set [warning|tripwire]"), + vec!["set warning", "set tripwire"] + ); +} diff --git a/crates/guardian_core/Cargo.toml b/crates/guardian_core/Cargo.toml index 7065265..2afe759 100644 --- a/crates/guardian_core/Cargo.toml +++ b/crates/guardian_core/Cargo.toml @@ -5,12 +5,40 @@ edition.workspace = true repository.workspace = true license.workspace = true -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[lints] +workspace = true [dependencies] bevy.workspace = true dcs-grpc.workspace = true +guardian_commands.workspace = true +serde.workspace = true +serde_json.workspace = true +shlex.workspace = true tokio.workspace = true async-compat = "0.2.1" +serde_repr = "0.1" crossbeam-channel = "0.5.9" tonic = "0.10" +tokio-util = { version = "0.7", features = ["codec", "net"] } +tokio-stream = { version = "0.1", features = ["sync"] } +futures-util = { version = "0.3", features = ["sink"] } +futures-core = { version = "0.3" } +base64 = "0.21.5" +byteorder = "1" +bytes = "1" +uuid = { version = "1.1", features = ["v4"] } +reqwest = { version = "0.11.22", features = ["blocking", "multipart"] } +audiopus = "0.3.0-rc.0" +thiserror = "1.0" +symspell = "0.4.3" +simsearch = "0.2" + +[target.'cfg(target_os = "windows")'.dependencies.windows] +version = "0.52" +features = [ + "Foundation", + "Foundation_Collections", + "Storage_Streams", + "Media_SpeechSynthesis", +] diff --git a/crates/guardian_core/src/components/awacs.rs b/crates/guardian_core/src/components/awacs.rs new file mode 100644 index 0000000..785d2bf --- /dev/null +++ b/crates/guardian_core/src/components/awacs.rs @@ -0,0 +1,4 @@ +use bevy::ecs::component::Component; + +#[derive(Component)] +pub struct Awacs; diff --git a/crates/guardian_core/src/components/callsign.rs b/crates/guardian_core/src/components/callsign.rs new file mode 100644 index 0000000..2d3a668 --- /dev/null +++ b/crates/guardian_core/src/components/callsign.rs @@ -0,0 +1,105 @@ +use std::{ + borrow::Cow, + hash::{Hash, Hasher}, +}; + +use bevy::{ + ecs::{component::Component, reflect::ReflectComponent}, + reflect::{std_traits::ReflectDefault, Reflect}, + utils::AHasher, +}; + +#[derive(Reflect, Component, Clone)] +#[reflect(Component, Default, Debug)] +pub struct Callsign { + hash: u64, + value: Cow<'static, str>, +} + +impl Default for Callsign { + fn default() -> Self { + Callsign::new("") + } +} + +fn add_spaces_around_numbers(input: &str) -> String { + let mut result = String::new(); + let mut chars = input.chars().peekable(); + + while let Some(c) = chars.next() { + result.push(c); + + if let Some(next_char) = chars.peek() { + if next_char.is_numeric() { + result.push(' '); + } + } + } + + result +} + +impl Callsign { + /// Creates a new [`Callsign`] from any string-like type. + /// + /// The internal hash will be computed immediately. + pub fn new(value: impl Into>) -> Self { + let value = value.into(); + + let mut value = Callsign { value, hash: 0 }; + value.update_hash(); + value + } + + pub fn to_voice(&self) -> String { + let value: String = self.value.to_string(); + let value = value.replace(' ', ""); + let value = value.replace('-', ""); + + add_spaces_around_numbers(&value) + } + + /// Sets the entity's value. + /// + /// The internal hash will be re-computed. + #[inline(always)] + pub fn set(&mut self, value: impl Into>) { + *self = Callsign::new(value); + } + + /// Updates the value of the entity in place. + /// + /// This will allocate a new string if the value was previously + /// created from a borrow. + #[inline(always)] + pub fn mutate(&mut self, f: F) { + f(self.value.to_mut()); + self.update_hash(); + } + + /// Gets the value of the entity as a `&str`. + #[inline(always)] + pub fn as_str(&self) -> &str { + &self.value + } + + fn update_hash(&mut self) { + let mut hasher = AHasher::default(); + self.value.hash(&mut hasher); + self.hash = hasher.finish(); + } +} + +impl std::fmt::Display for Callsign { + #[inline(always)] + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + std::fmt::Display::fmt(&self.value, f) + } +} + +impl std::fmt::Debug for Callsign { + #[inline(always)] + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + std::fmt::Debug::fmt(&self.value, f) + } +} diff --git a/crates/guardian_core/src/components/group.rs b/crates/guardian_core/src/components/group.rs new file mode 100644 index 0000000..841d1ac --- /dev/null +++ b/crates/guardian_core/src/components/group.rs @@ -0,0 +1,7 @@ +use bevy::ecs::component::Component; + +#[derive(Debug, Component)] +pub struct Group { + pub id: u32, + pub unit: u32, +} diff --git a/crates/guardian_core/src/grpc_base_url.rs b/crates/guardian_core/src/components/grpc_base_url.rs similarity index 100% rename from crates/guardian_core/src/grpc_base_url.rs rename to crates/guardian_core/src/components/grpc_base_url.rs diff --git a/crates/guardian_core/src/components/heading.rs b/crates/guardian_core/src/components/heading.rs new file mode 100644 index 0000000..b116c27 --- /dev/null +++ b/crates/guardian_core/src/components/heading.rs @@ -0,0 +1,4 @@ +use bevy::ecs::component::Component; + +#[derive(Debug, Component)] +pub struct Heading(pub f64); diff --git a/crates/guardian_core/src/components/id.rs b/crates/guardian_core/src/components/id.rs new file mode 100644 index 0000000..5f81d22 --- /dev/null +++ b/crates/guardian_core/src/components/id.rs @@ -0,0 +1,22 @@ +use bevy::ecs::component::Component; + +#[derive(Debug, Component, Copy, Clone, PartialEq)] +pub struct Id(u32); + +impl Id { + pub fn new(value: u32) -> Self { + Self(value) + } +} + +impl From<&Id> for u32 { + fn from(value: &Id) -> Self { + value.0 + } +} + +impl From for u32 { + fn from(value: Id) -> Self { + value.0 + } +} diff --git a/crates/guardian_core/src/components/mod.rs b/crates/guardian_core/src/components/mod.rs new file mode 100644 index 0000000..c260c75 --- /dev/null +++ b/crates/guardian_core/src/components/mod.rs @@ -0,0 +1,23 @@ +mod awacs; +mod callsign; +mod group; +mod grpc_base_url; +mod heading; +mod id; +mod player; +mod position; +mod side; +mod srs_socket_addr; +mod unit_type; + +pub use awacs::*; +pub use callsign::*; +pub use group::*; +pub use grpc_base_url::*; +pub use heading::*; +pub use id::*; +pub use player::*; +pub use position::*; +pub use side::*; +pub use srs_socket_addr::*; +pub use unit_type::*; diff --git a/crates/guardian_core/src/components/player.rs b/crates/guardian_core/src/components/player.rs new file mode 100644 index 0000000..6d9836d --- /dev/null +++ b/crates/guardian_core/src/components/player.rs @@ -0,0 +1,79 @@ +use std::{ + borrow::Cow, + hash::{Hash, Hasher}, +}; + +use bevy::{ + ecs::{component::Component, reflect::ReflectComponent}, + reflect::{std_traits::ReflectDefault, Reflect}, + utils::AHasher, +}; + +#[derive(Reflect, Component, Clone)] +#[reflect(Component, Default, Debug)] +pub struct Player { + hash: u64, + value: Cow<'static, str>, +} + +impl Default for Player { + fn default() -> Self { + Player::new("") + } +} + +impl Player { + /// Creates a new [`Player`] from any string-like type. + /// + /// The internal hash will be computed immediately. + pub fn new(value: impl Into>) -> Self { + let value = value.into(); + let mut value = Player { value, hash: 0 }; + value.update_hash(); + value + } + + /// Sets the entity's value. + /// + /// The internal hash will be re-computed. + #[inline(always)] + pub fn set(&mut self, value: impl Into>) { + *self = Player::new(value); + } + + /// Updates the value of the entity in place. + /// + /// This will allocate a new string if the value was previously + /// created from a borrow. + #[inline(always)] + pub fn mutate(&mut self, f: F) { + f(self.value.to_mut()); + self.update_hash(); + } + + /// Gets the value of the entity as a `&str`. + #[inline(always)] + pub fn as_str(&self) -> &str { + &self.value + } + + fn update_hash(&mut self) { + let mut hasher = AHasher::default(); + self.value.hash(&mut hasher); + self.hash = hasher.finish(); + } +} + +impl std::fmt::Display for Player { + #[inline(always)] + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + std::fmt::Display::fmt(&self.value, f) + } +} + +impl std::fmt::Debug for Player { + #[inline(always)] + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + std::fmt::Debug::fmt(&self.value, f) + } +} diff --git a/crates/guardian_core/src/components/position.rs b/crates/guardian_core/src/components/position.rs new file mode 100644 index 0000000..4c5c13b --- /dev/null +++ b/crates/guardian_core/src/components/position.rs @@ -0,0 +1,49 @@ +use bevy::ecs::component::Component; + +#[derive(Debug, Component, Clone)] +pub struct Position { + pub lat: f64, + pub long: f64, + pub altitude: f64, +} +const COMPASS_DRIFT: i32 = -5; + +impl Position { + pub fn distance_to_km(&self, other: &Position) -> f64 { + let radius_of_earth_in_km: f64 = 6371.0; + let delta_lat = (other.lat - self.lat).to_radians(); + let delta_long = (other.long - self.long).to_radians(); + + let a = (delta_lat / 2.0).sin() * (delta_lat / 2.0).sin() + + self.lat.to_radians().cos() + * other.lat.to_radians().cos() + * (delta_long / 2.0).sin() + * (delta_long / 2.0).sin(); + + let c = 2.0 * (a.sqrt().atan2((1.0 - a).sqrt())); + radius_of_earth_in_km * c + } + + pub fn distance_to_nmi(&self, other: &Position) -> f64 { + self.distance_to_km(other) * 0.5399568035 + } + + pub fn get_bearing_to(&self, other: &Position) -> i32 { + let delta_long = (other.long - self.long).to_radians(); + + let y = + (other.long.to_radians() - self.long.to_radians()).sin() * other.lat.to_radians().cos(); + let x = self.lat.to_radians().cos() * other.lat.to_radians().sin() + - self.lat.to_radians().sin() * other.lat.to_radians().cos() * delta_long.cos(); + + (((y.atan2(x).to_degrees() + 360.0) % 360.0) as i32) + COMPASS_DRIFT + } + + pub fn feet(&self) -> f64 { + self.altitude * 3.28084 + } + + pub fn angels(&self) -> i32 { + (self.feet() / 1000.0).round() as i32 + } +} diff --git a/crates/guardian_core/src/components/side.rs b/crates/guardian_core/src/components/side.rs new file mode 100644 index 0000000..1c1849f --- /dev/null +++ b/crates/guardian_core/src/components/side.rs @@ -0,0 +1,10 @@ +use bevy::ecs::component::Component; + +#[derive(Debug, Component)] +pub struct All; +#[derive(Debug, Component)] +pub struct Neutral; +#[derive(Debug, Component)] +pub struct Red; +#[derive(Debug, Component)] +pub struct Blue; diff --git a/crates/guardian_core/src/components/srs_socket_addr.rs b/crates/guardian_core/src/components/srs_socket_addr.rs new file mode 100644 index 0000000..31d6beb --- /dev/null +++ b/crates/guardian_core/src/components/srs_socket_addr.rs @@ -0,0 +1,30 @@ +use std::net::SocketAddr; + +use bevy::ecs::system::Resource; + +#[derive(Debug, Resource, Clone)] +pub struct SrsSocketAddr(SocketAddr); + +impl Default for SrsSocketAddr { + fn default() -> Self { + Self("127.0.0.1:5002".parse().unwrap()) + } +} + +impl SrsSocketAddr { + pub fn new(addr: SocketAddr) -> Self { + Self(addr) + } +} + +impl From for SrsSocketAddr { + fn from(value: SocketAddr) -> Self { + Self(value) + } +} + +impl From for SocketAddr { + fn from(value: SrsSocketAddr) -> Self { + value.0 + } +} diff --git a/crates/guardian_core/src/components/unit_type.rs b/crates/guardian_core/src/components/unit_type.rs new file mode 100644 index 0000000..c32b9fc --- /dev/null +++ b/crates/guardian_core/src/components/unit_type.rs @@ -0,0 +1,79 @@ +use std::{ + borrow::Cow, + hash::{Hash, Hasher}, +}; + +use bevy::{ + ecs::{component::Component, reflect::ReflectComponent}, + reflect::{std_traits::ReflectDefault, Reflect}, + utils::AHasher, +}; + +#[derive(Reflect, Component, Clone)] +#[reflect(Component, Default, Debug)] +pub struct UnitType { + hash: u64, + value: Cow<'static, str>, +} + +impl Default for UnitType { + fn default() -> Self { + UnitType::new("") + } +} + +impl UnitType { + /// Creates a new [`UnitType`] from any string-like type. + /// + /// The internal hash will be computed immediately. + pub fn new(value: impl Into>) -> Self { + let value = value.into(); + let mut value = UnitType { value, hash: 0 }; + value.update_hash(); + value + } + + /// Sets the entity's value. + /// + /// The internal hash will be re-computed. + #[inline(always)] + pub fn set(&mut self, value: impl Into>) { + *self = UnitType::new(value); + } + + /// Updates the value of the entity in place. + /// + /// This will allocate a new string if the value was previously + /// created from a borrow. + #[inline(always)] + pub fn mutate(&mut self, f: F) { + f(self.value.to_mut()); + self.update_hash(); + } + + /// Gets the value of the entity as a `&str`. + #[inline(always)] + pub fn as_str(&self) -> &str { + &self.value + } + + fn update_hash(&mut self) { + let mut hasher = AHasher::default(); + self.value.hash(&mut hasher); + self.hash = hasher.finish(); + } +} + +impl std::fmt::Display for UnitType { + #[inline(always)] + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + std::fmt::Display::fmt(&self.value, f) + } +} + +impl std::fmt::Debug for UnitType { + #[inline(always)] + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + std::fmt::Debug::fmt(&self.value, f) + } +} diff --git a/crates/guardian_core/src/dcs.rs b/crates/guardian_core/src/dcs.rs index 8fea02b..4a941ff 100644 --- a/crates/guardian_core/src/dcs.rs +++ b/crates/guardian_core/src/dcs.rs @@ -1,14 +1,15 @@ -mod mission; -mod text; -mod voice; +pub mod mission; +pub mod text; -use crate::GrpcBaseUrl; +use crate::components::GrpcBaseUrl; -pub use self::mission::*; -pub use self::text::*; +use mission::MissionPlugin; +use text::TextPlugin; + +// Re-export +pub use dcs_grpc::dcs::common::v0::Coalition; use bevy::app::{App, Plugin, ScheduleRunnerPlugin}; -pub use dcs_grpc::dcs::common::v0::{Coalition, Unit}; use std::time::Duration; pub struct DcsPlugin; diff --git a/crates/guardian_core/src/dcs/mission.rs b/crates/guardian_core/src/dcs/mission.rs index c0f4d92..813a1c5 100644 --- a/crates/guardian_core/src/dcs/mission.rs +++ b/crates/guardian_core/src/dcs/mission.rs @@ -1,13 +1,14 @@ use std::time::Duration; -use crate::{GrpcBaseUrl, TokioResource}; +use crate::{components::*, TokioResource}; use bevy::{ - app::{App, Plugin, PreStartup, PreUpdate}, + app::{App, Plugin, PostUpdate, PreStartup, PreUpdate}, core::Name, ecs::{ component::Component, entity::Entity, event::{Event, EventReader, EventWriter}, + query::With, system::{Commands, Query, Res}, }, }; @@ -20,6 +21,12 @@ use dcs_grpc::dcs::{ }, }; +#[derive(Clone)] +enum Response { + Disconnected, + Update(Box), +} + pub struct MissionPlugin; impl Plugin for MissionPlugin { @@ -31,7 +38,8 @@ impl Plugin for MissionPlugin { app.add_systems( PreUpdate, (consume_stream_message, update_units, despawn_units), - ); + ) + .add_systems(PostUpdate, cleanup_after_disconnect); } } @@ -60,16 +68,27 @@ fn connect_to_grpc(mut commands: Commands, tokio: Res, url: Res { + if let Some(update) = &next.update { + tx.send(Response::Update(Box::new(update.clone()))).ok(); + } + } + Ok(None) => { + // eprintln!("Empty?"); + break; + } + Err(e) => { + eprintln!("Error from gRPC: {:?}", e); + break; + } } - - message = stream.get_mut().message().await; } eprintln!("Disconnected"); + tx.send(Response::Disconnected).ok(); + tokio::time::sleep(Duration::from_secs(5)).await; } }); @@ -77,7 +96,23 @@ fn connect_to_grpc(mut commands: Commands, tokio: Res, url: Res>, + entitiews: Query>, +) { + for d in disconnected.iter() { + // if there is one + commands.entity(d).despawn(); + + for ent in entitiews.iter() { + commands.entity(ent).despawn(); + } + } +} + +fn consume_stream_message( + mut commands: Commands, requests: Query<&UnitsRequestMessage>, mut ev_unit_updated: EventWriter, mut ev_unit_gone: EventWriter, @@ -85,18 +120,23 @@ pub(crate) fn consume_stream_message( for stream in requests.iter() { if let Ok(update) = stream.0.try_recv() { match update { - Update::Gone(unit) => { - ev_unit_gone.send(UnitGoneEvent(unit.id)); - } - Update::Unit(unit) => { - ev_unit_updated.send(UnitUpdatedEvent(unit)); + Response::Update(boxed) => match *boxed { + Update::Gone(unit) => { + ev_unit_gone.send(UnitGoneEvent(unit.id)); + } + Update::Unit(unit) => { + ev_unit_updated.send(UnitUpdatedEvent(unit)); + } + }, + Response::Disconnected => { + commands.spawn(Disconnected); } } } } } -pub(crate) fn update_units( +fn update_units( mut commands: Commands, units: Query<(Entity, &Id)>, mut ev: EventReader, @@ -106,14 +146,14 @@ pub(crate) fn update_units( let mut e: Option<_> = None; for (ent, id) in units.iter() { - if id.0 == event.id { + if &Id::new(event.id) == id { e = commands.get_entity(ent); break; } } if e.is_none() { - e = Some(commands.spawn((Id(event.id), Callsign(event.callsign.clone())))); + e = Some(commands.spawn((Id::new(event.id), Callsign::new(event.callsign.clone())))); } let Some(mut e) = e else { @@ -133,7 +173,7 @@ pub(crate) fn update_units( } if let Some(playername) = &event.player_name { - e.insert(Player(playername.clone())); + e.insert(Player::new(playername.clone())); } if let Some(group) = &event.group { @@ -145,23 +185,36 @@ pub(crate) fn update_units( e.insert(Name::new(event.name.clone())); - e.insert(UnitType(event.r#type.clone())); + if ["A-50", "E-3A", "E-2C", "KJ-2000"].contains(&event.r#type.as_str()) { + e.insert(Awacs); + } - e.insert(Side( - Coalition::try_from(event.coalition).expect("Coalition to be correct"), - )); + e.insert(UnitType::new(event.r#type.clone())); + + { + e.remove::(); + e.remove::(); + e.remove::(); + e.remove::(); + match Coalition::try_from(event.coalition).expect("Coalition to be correct") { + Coalition::All => e.insert(All), + Coalition::Neutral => e.insert(Neutral), + Coalition::Red => e.insert(Red), + Coalition::Blue => e.insert(Blue), + }; + } } } -pub(crate) fn despawn_units( +fn despawn_units( mut commands: Commands, units: Query<(Entity, &Id)>, mut ev: EventReader, ) { for event in ev.read() { - let gid = &event.0; + let gid = event.0; for (ent, id) in units.iter() { - if gid == &id.0 { + if &Id::new(gid) == id { commands.entity(ent).despawn(); } } @@ -169,83 +222,13 @@ pub(crate) fn despawn_units( } #[derive(Event)] -pub(crate) struct UnitUpdatedEvent(Unit); +struct UnitUpdatedEvent(Unit); #[derive(Event)] -pub(crate) struct UnitGoneEvent(u32); +struct UnitGoneEvent(u32); #[derive(Component)] -pub(crate) struct UnitsRequestMessage(Receiver); +struct UnitsRequestMessage(Receiver); -#[derive(Debug, Component)] -pub struct Id(pub u32); - -#[derive(Debug, Component)] -pub struct Callsign(pub String); - -#[derive(Debug, Component)] -pub struct UnitType(pub String); - -#[derive(Debug, Component)] -pub struct Player(pub String); - -#[derive(Debug, Component)] -pub struct Side(pub Coalition); - -#[derive(Debug, Component)] -pub struct Position { - pub lat: f64, - pub long: f64, - pub altitude: f64, -} - -#[derive(Debug, Component)] -pub struct Heading(pub f64); - -#[derive(Debug, Component)] -pub struct Group { - pub id: u32, - pub unit: u32, -} - -const COMPASS_DRIFT: i32 = -5; - -impl Position { - pub fn distance_to_km(&self, other: &Position) -> f64 { - let radius_of_earth_in_km: f64 = 6371.0; - let delta_lat = (other.lat - self.lat).to_radians(); - let delta_long = (other.long - self.long).to_radians(); - - let a = (delta_lat / 2.0).sin() * (delta_lat / 2.0).sin() - + self.lat.to_radians().cos() - * other.lat.to_radians().cos() - * (delta_long / 2.0).sin() - * (delta_long / 2.0).sin(); - - let c = 2.0 * (a.sqrt().atan2((1.0 - a).sqrt())); - radius_of_earth_in_km * c - } - - pub fn distance_to_nmi(&self, other: &Position) -> f64 { - self.distance_to_km(other) * 0.5399568035 - } - - pub fn get_bearing_to(&self, other: &Position) -> i32 { - let delta_long = (other.long - self.long).to_radians(); - - let y = - (other.long.to_radians() - self.long.to_radians()).sin() * other.lat.to_radians().cos(); - let x = self.lat.to_radians().cos() * other.lat.to_radians().sin() - - self.lat.to_radians().sin() * other.lat.to_radians().cos() * delta_long.cos(); - - (((y.atan2(x).to_degrees() + 360.0) % 360.0) as i32) + COMPASS_DRIFT - } - - pub fn feet(&self) -> f64 { - self.altitude * 3.28084 - } - - pub fn angels(&self) -> i32 { - (self.feet() / 1000.0).round() as i32 - } -} +#[derive(Component)] +struct Disconnected; diff --git a/crates/guardian_core/src/dcs/text.rs b/crates/guardian_core/src/dcs/text.rs index 679b61c..6401986 100644 --- a/crates/guardian_core/src/dcs/text.rs +++ b/crates/guardian_core/src/dcs/text.rs @@ -20,7 +20,7 @@ use dcs_grpc::dcs::{ net::v0::{net_service_client::NetServiceClient, SendChatRequest}, }; -use crate::{GrpcBaseUrl, TokioResource}; +use crate::{components::GrpcBaseUrl, TokioResource}; pub struct TextPlugin; diff --git a/crates/guardian_core/src/lib.rs b/crates/guardian_core/src/lib.rs index bfdb163..4f4cffc 100644 --- a/crates/guardian_core/src/lib.rs +++ b/crates/guardian_core/src/lib.rs @@ -1,12 +1,15 @@ -mod dcs; -mod grpc_base_url; +pub mod components; +pub mod dcs; +pub mod srs; +mod tts; -pub use dcs::*; -pub use grpc_base_url::*; +use dcs::DcsPlugin; +use srs::SrsPlugin; -use bevy::app::PluginGroup; -use bevy::app::PluginGroupBuilder; -use bevy::ecs::system::Resource; +use bevy::{ + app::{PluginGroup, PluginGroupBuilder}, + ecs::system::Resource, +}; use tokio::runtime::Handle; pub struct DefaultPlugins; @@ -14,7 +17,9 @@ pub struct DefaultPlugins; impl PluginGroup for DefaultPlugins { fn build(self) -> PluginGroupBuilder { #[allow(unused_mut)] - let mut group = PluginGroupBuilder::start::().add(DcsPlugin); + let mut group = PluginGroupBuilder::start::() + .add(DcsPlugin) + .add(SrsPlugin); group } diff --git a/crates/guardian_core/src/srs.rs b/crates/guardian_core/src/srs.rs new file mode 100644 index 0000000..072682e --- /dev/null +++ b/crates/guardian_core/src/srs.rs @@ -0,0 +1,559 @@ +pub mod frame_queue; +mod message; +mod messages_codec; +pub mod voice; +mod voice_codec; +mod voice_command; + +use guardian_commands::{call::parse_call, ConsoleCommandEntered, ConsoleConfiguration}; +use simsearch::SimSearch; +pub use voice_command::VoiceCommand; + +use crossbeam_channel::Receiver; +pub use message::{Modulation, RadioInfo}; + +use std::{ + collections::{hash_map::Entry, HashMap}, + net::SocketAddr, + sync::Arc, + time::Duration, +}; + +const SRS_VERSION: &str = "1.9.0.0"; + +use bevy::{ + app::{Plugin, PostUpdate, Update}, + ecs::{ + component::Component, + entity::Entity, + event::EventWriter, + query::{Added, Changed, With}, + system::{Commands, Query, Res}, + }, +}; +use futures_util::{SinkExt, StreamExt}; +use tokio::{ + net::{TcpStream, UdpSocket}, + sync::{mpsc::Sender, RwLock}, + task::JoinHandle, + time::sleep, +}; + +use tokio_util::{ + codec::{FramedRead, FramedWrite}, + udp::UdpFramed, +}; + +use crate::{ + components::*, + srs::{ + message::{Message, SyncMessage, VersionMismatchMessage}, + voice_codec::{Encryption, Frequency}, + }, + tts::synthesize, + TokioResource, +}; + +use self::{ + frame_queue::FrameQueue, + message::{ + create_sguid, Client, Coalition, MessageRequest, MsgType, RadioUpdateMessage, + SyncMessageRequest, UpdateMessage, + }, + messages_codec::MessagesCodec, + voice::VoiceMessage, + voice_codec::{VoiceCodec, VoicePacket}, +}; + +pub struct SrsPlugin; + +#[derive(Component)] +struct MessageComponent(Receiver); + +#[derive(Debug, Clone)] +pub struct ReceivedMessage { + pub unit_sguid: String, + pub message: String, +} + +impl Plugin for SrsPlugin { + fn build(&self, app: &mut bevy::prelude::App) { + app.world + .get_resource_or_insert_with(SrsSocketAddr::default); + app.add_systems(Update, (listen_srs, update_srs_position, consume_message)); + app.add_systems( + PostUpdate, + (transmit_message, update_client_radio, handle_voice_command), + ); + } +} + +fn listen_srs( + mut commands: Commands, + mut units: Query< + ( + Entity, + &Id, + &Callsign, + &Position, + &mut Radio, + Option<&Red>, + Option<&Blue>, + ), + Added, + >, + tokio: Res, + addr: Res, +) { + for (ent, id, callsign, position, mut radio, red, blue) in units.iter_mut() { + let addr: SocketAddr = addr.clone().into(); + let callsign = callsign.as_str().to_string(); + + let coalition = if red.is_some() { + Coalition::Red + } else if blue.is_some() { + Coalition::Blue + } else { + continue; // no coalition, no comms + }; + + let (tx, task) = crossbeam_channel::unbounded(); + commands.entity(ent).insert(MessageComponent(task)); + + let client = Client { + client_guid: radio.sguid.clone(), + name: callsign.clone(), + seat: 0, + coalition, + allow_record: false, + radio_info: Some(RadioInfo { + radios: vec![message::Radio { + enc: false, + enc_key: 1, + freq: radio.frequency as f64, + modulation: radio.modulation, + sec_freq: 1.0, + retransmit: false, + }], + unit: callsign.clone(), + unit_id: id.into(), + iff: Default::default(), + }), + lat_lng_position: message::Position { + lat: position.lat, + lon: position.long, + alt: position.altitude, + }, + }; + + let mut sguid = [0; 22]; + sguid.clone_from_slice(radio.sguid.as_bytes()); + + let (voice_sink, mut voice_handle) = tokio::sync::mpsc::channel(128); + let (message_sink, mut message_handle) = tokio::sync::mpsc::channel(128); + let (client_sink, client_handle) = crossbeam_channel::unbounded(); + + radio.voice_sink = Some(voice_sink); + radio.message_sink = Some(message_sink); + commands.entity(ent).insert(ClientHandler(client_handle)); + + let frequency = radio.frequency; + let id = *id; + radio.handle = Some(tokio.0.spawn(async move { + let tcp = TcpStream::connect(addr).await?; + + let (tcp_stream, tcp_sink) = tcp.into_split(); + let mut messages_sink = FramedWrite::new(tcp_sink, MessagesCodec::new()); + let mut messages_stream = FramedRead::new(tcp_stream, MessagesCodec::new()); + + let udp = UdpSocket::bind(SocketAddr::from(([0, 0, 0, 0], 0))).await?; + udp.connect(addr).await?; + + let mut voice_ping_interval = tokio::time::interval(Duration::from_secs(15)); + let (mut voice_sink, mut voice_stream) = UdpFramed::new(udp, VoiceCodec::new()).split(); + + let mut packet_id = 1; + + messages_sink + .send(MessageRequest::Sync(SyncMessageRequest { + msg_type: MsgType, + client, + version: SRS_VERSION.to_string(), + })) + .await?; + + let transmissions = Arc::new(RwLock::new(HashMap::>::new())); + + let frames: FrameQueue>> = FrameQueue::new(); + + loop { + tokio::select! { + Some(data) = frames.next() => { + let start = tokio::time::Instant::now(); + for (i, frame) in data.into_iter().enumerate() { + if frame.is_empty() { + continue; + } + + let packet = VoicePacket { + audio_part: frame, + wav_audio_part: None, + frequencies: vec![Frequency { + freq: frequency as f64, + modulation: if frequency <= 87_995_000 { + voice_codec::Modulation::Fm + } else { + voice_codec::Modulation::Am + }, + encryption: Encryption::None, + }], + unit_id: id.into(), + packet_id, + hop_count: 0, + transmission_sguid: sguid, + client_sguid: sguid, + }; + voice_sink.send((packet.into(), addr)).await.ok(); + + packet_id = packet_id.wrapping_add(1); + + let playtime = Duration::from_millis((i as u64 + 1) * 20); // 20m per frame count + let elapsed = start.elapsed(); + if playtime > elapsed { + let s = playtime - elapsed; + sleep(s).await; + } + } + } + Some(data) = message_handle.recv() => { + messages_sink.send(data).await?; + } + Some(Ok(data)) = messages_stream.next() => { + + match &data { + Message::VersionMismatch(VersionMismatchMessage { version, .. }) => { + eprintln!("Version mismatch {} != {}", SRS_VERSION, version); + }, + Message::Sync(SyncMessage { clients, .. }) => { + for client in clients.iter() { + client_sink.send(client.clone()).ok(); + } + } + Message::RadioUpdate(RadioUpdateMessage { client, .. })=> { + client_sink.send(client.clone()).ok(); + } + _ => {}, + } + } + Some(data) = voice_handle.recv() => { + frames.push(synthesize(data.as_str()).await?).await; + } + Some(Ok(data)) = voice_stream.next() => { + // Collect voice packets + let (data, _) = data; + + let unit_id = data.unit_id; + + let mut t = transmissions.write().await; + if let Entry::Vacant(e) = t.entry(unit_id) + { + e.insert(vec![data]); + } else { + let t1 = t.get_mut(&unit_id).unwrap(); + t1.push(data); + } + } + _ = tokio::time::sleep(Duration::from_millis(200)) => { + // Process voice packets + if transmissions.read().await.is_empty() { + continue; + } + + let mut transmissions = transmissions.write().await; + for (_, data) in transmissions.iter() { + let mut wav_data = vec![]; + let mut unit_sguid = String::new(); + for d in data { + unit_sguid = String::from_utf8_lossy(&d.client_sguid).to_string(); + if let Some(wav) = &d.wav_audio_part { + wav_data.extend_from_slice(wav); + } + } + + let Some(message) = whisper("http://192.168.0.2:3000/", &wav_data).await else { + continue; + }; + + tx.send(ReceivedMessage { + unit_sguid, + message + }).ok(); + } + transmissions.clear(); + } + _ = voice_ping_interval.tick() => { + voice_sink.send((voice_codec::Packet::Ping(sguid), addr)).await?; + } + }; + } + #[allow(unreachable_code)] + Ok(()) + })); + } +} + +fn consume_message( + mut commands: Commands, + messages: Query<(Entity, &MessageComponent)>, + units: Query<(Entity, &Sguid)>, +) { + for (entity, message) in messages.iter() { + if let Ok(message) = message.0.try_recv() { + for (ent, sguid) in units.iter() { + if sguid.0 == message.unit_sguid { + commands + .entity(ent) + .insert(VoiceCommand::new(message.message.clone(), entity)); + break; + } + } + } + } +} + +fn transmit_message( + mut commands: Commands, + messages: Query<(Entity, &Radio, &VoiceMessage), Added>, +) { + for (ent, radio, msg) in messages.iter() { + if let Some(sink) = &radio.voice_sink { + sink.blocking_send(msg.clone()).ok(); + commands.entity(ent).remove::(); + } + } +} + +#[derive(Component)] +struct Sguid(String); + +fn update_client_radio( + mut commands: Commands, + players: Query<(Entity, &Player), With>, + clients: Query<&ClientHandler>, +) { + let Some(client) = clients.iter().next() else { + return; // there is no client handler, and if there is, we only need one + }; + + if let Ok(client) = client.0.try_recv() { + for (ent, player) in players.iter() { + if player.as_str() == client.name { + commands + .entity(ent) + .insert(Sguid(client.client_guid.clone())); + if let Some(radio_info) = &client.radio_info { + commands.entity(ent).insert(radio_info.clone()); + } + } + } + } +} + +fn update_srs_position( + units: Query< + ( + &Id, + &Callsign, + &Position, + &Radio, + Option<&Red>, + Option<&Blue>, + ), + Changed, + >, +) { + for (id, callsign, position, radio, red, blue) in units.iter() { + let Some(message) = &radio.message_sink else { + continue; // no sink available + }; + + let coalition = if red.is_some() { + Coalition::Red + } else if blue.is_some() { + Coalition::Blue + } else { + continue; // no coalition, no comms + }; + + let callsign = callsign.as_str().to_string(); + + let client = Client { + client_guid: radio.sguid.clone(), + name: callsign.clone(), + seat: 0, + coalition, + allow_record: false, + radio_info: Some(RadioInfo { + radios: vec![message::Radio { + enc: false, + enc_key: 1, + freq: radio.frequency as f64, + modulation: radio.modulation, + sec_freq: 1.0, + retransmit: false, + }], + unit: callsign.clone(), + unit_id: id.into(), + iff: Default::default(), + }), + lat_lng_position: message::Position { + lat: position.lat, + lon: position.long, + alt: position.altitude, + }, + }; + + message + .blocking_send(MessageRequest::Update(UpdateMessage { + msg_type: MsgType, + client: client.clone(), + version: SRS_VERSION.to_string(), + })) + .ok(); + + message + .blocking_send(MessageRequest::RadioUpdate(RadioUpdateMessage { + msg_type: MsgType, + client: client.clone(), + version: SRS_VERSION.to_string(), + })) + .ok(); + } +} + +fn handle_voice_command( + awacs: Query<(Entity, &Callsign)>, + cmds: Query<(Entity, &Callsign, &VoiceCommand), Changed>, + config: Res, + mut command_entered: EventWriter, +) { + for (ent, callsign, voice_command) in cmds.iter() { + let Ok((awacs_ent, awacs)) = awacs.get(*voice_command.get_entity()) else { + continue; + }; + + let all_commands = config.commands.keys().collect::>(); + + let rawr = voice_command.as_str().to_lowercase(); + let rawr = rawr.replace(&['(', ')', ',', '\"', '.', ';', ':', '\'', '?'][..], " "); + + if rawr.is_empty() { + continue; + } + + let mut engine: SimSearch = SimSearch::new(); + for cmd in &all_commands { + let awacs = awacs.to_voice(); + let awacs = awacs.split(' ').next().unwrap(); + let str = format!("{} {} {}", awacs, callsign.to_voice(), cmd); + engine.insert(str.to_string(), &str); + } + let results = engine.search(&rawr); + + let Some(cmd) = results.first() else { + // no command, casual conversations? + continue; + }; + + let Ok((_, call)) = parse_call(cmd) else { + // no command, casual conversations? + return; + }; + + let cmd = call.command; + + if config.commands.get(&cmd).is_some() { + let raw = rawr.clone(); + let raw = raw.trim().to_string(); + + command_entered.send(ConsoleCommandEntered { + command_name: cmd.clone(), + raw, + args: vec![], + pilot: ent, + operator: awacs_ent, + }); + } + } +} + +#[derive(Component)] +pub struct ClientHandler(Receiver); + +#[derive(Debug, Component)] +pub struct Radio { + pub frequency: u64, // the way srs wants it + pub modulation: Modulation, + sguid: String, + voice_sink: Option>, + message_sink: Option>, + handle: Option>>>, +} + +impl Radio { + pub fn new(frequency: u64, modulation: Modulation) -> Self { + Self { + frequency, + modulation, + sguid: create_sguid(), + voice_sink: None, + message_sink: None, + handle: None, + } + } +} + +impl Drop for Radio { + fn drop(&mut self) { + if let Some(handle) = &self.handle { + handle.abort(); + } + } +} + +async fn whisper(url: &str, data: &[i16]) -> Option { + use byteorder::{LittleEndian, WriteBytesExt}; + // convert i16 to u8 + let mut result: Vec = Vec::new(); + for &n in data { + let _ = result.write_i16::(n); + } + + let file_part = reqwest::multipart::Part::bytes(result); + let client = reqwest::ClientBuilder::new() + .timeout(Duration::from_secs(30)) + .build() + .unwrap(); + let form = reqwest::multipart::Form::new().part("file", file_part); + let res = client + .post(url.to_owned()) + .multipart(form) + .header("Content-Type", "multipart/form-data") + .send() + .await; + + res.ok()?.text().await.ok()?.trim().to_string().non_empty() +} + +trait NonEmpty { + fn non_empty(self) -> Option; +} + +impl NonEmpty for String { + fn non_empty(self) -> Option { + if self.is_empty() { + return None; + } + Some(self) + } +} diff --git a/crates/guardian_core/src/srs/frame_queue.rs b/crates/guardian_core/src/srs/frame_queue.rs new file mode 100644 index 0000000..e964f24 --- /dev/null +++ b/crates/guardian_core/src/srs/frame_queue.rs @@ -0,0 +1,32 @@ +// a queue we can push to at will +// we need to take an item and read async,stream? +// pull from the front, push to the back + +use std::{collections::VecDeque, sync::Arc}; + +use tokio::sync::RwLock; + +pub struct FrameQueue { + backend: Arc>>, +} + +impl FrameQueue { + pub fn new() -> Self { + Self { + backend: Arc::new(RwLock::new(VecDeque::new())), + } + } + pub async fn push(&self, item: T) { + self.backend.write().await.push_back(item); + } + + pub async fn next(&self) -> Option { + self.backend.write().await.pop_front() + } +} + +impl Default for FrameQueue { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/guardian_core/src/srs/message.rs b/crates/guardian_core/src/srs/message.rs new file mode 100644 index 0000000..426c2f2 --- /dev/null +++ b/crates/guardian_core/src/srs/message.rs @@ -0,0 +1,338 @@ +use base64::{ + alphabet::URL_SAFE, + engine::{general_purpose::NO_PAD, GeneralPurpose}, + Engine, +}; +use bevy::ecs::component::Component; +use serde::{Deserialize, Serialize}; +use serde_repr::{Deserialize_repr, Serialize_repr}; +use std::{ + collections::HashMap, + error::Error, + fmt::{self, Display}, +}; +use uuid::Uuid; + +pub(crate) const BASE64: GeneralPurpose = base64::engine::GeneralPurpose::new(&URL_SAFE, NO_PAD); + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase", untagged)] +pub enum Message { + Update(UpdateMessage), + Ping(PingMessage), + Sync(SyncMessage), + RadioUpdate(RadioUpdateMessage), + ServerSettings(ServerSettingsMessage), + ClientDisconnect(ClientDisconnectMessage), + VersionMismatch(VersionMismatchMessage), +} + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase", untagged)] +pub enum MessageRequest { + Update(UpdateMessage), + Sync(SyncMessageRequest), + RadioUpdate(RadioUpdateMessage), +} + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct UpdateMessage { + pub msg_type: MsgType<0>, + pub client: Client, + pub version: String, +} + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct PingMessage { + pub msg_type: MsgType<1>, + pub version: String, +} + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct SyncMessage { + pub msg_type: MsgType<2>, + pub clients: Vec, + pub server_settings: HashMap, + pub version: String, +} + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct SyncMessageRequest { + pub msg_type: MsgType<2>, + pub client: Client, + pub version: String, +} + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct RadioUpdateMessage { + pub msg_type: MsgType<3>, + pub client: Client, + pub version: String, +} + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct ServerSettingsMessage { + pub msg_type: MsgType<4>, + pub server_settings: HashMap, + pub version: String, +} + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct ClientDisconnectMessage { + pub msg_type: MsgType<5>, + pub client: Client, + pub version: String, +} + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct VersionMismatchMessage { + pub msg_type: MsgType<6>, + pub version: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Coalition { + Spectator, + Blue, + Red, +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Radio { + pub enc: bool, + pub enc_key: u8, + pub freq: f64, + pub modulation: Modulation, + pub sec_freq: f64, + pub retransmit: bool, +} + +impl Default for Radio { + fn default() -> Self { + Radio { + enc: false, + enc_key: 0, + freq: 1.0, + modulation: Modulation::Disabled, + sec_freq: 1.0, + retransmit: false, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize_repr, Deserialize_repr)] +#[repr(u8)] +#[derive(Default)] +pub enum Modulation { + Am = 0, + Fm = 1, + Intercom = 2, + #[default] + Disabled = 3, + HaveQuick = 4, + Satcom = 5, + Mids = 6, +} + +#[derive(Component, Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RadioInfo { + pub radios: Vec, + pub unit: String, + pub unit_id: u32, + pub iff: Transponder, +} + +#[derive(Debug, PartialEq, Default, Clone, Copy, Serialize_repr, Deserialize_repr)] +#[repr(u8)] +pub enum RadioSwitchControls { + #[default] + Hotas = 0, + InCockpit = 1, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct Client { + pub client_guid: String, + pub name: String, + pub seat: u32, + pub coalition: Coalition, + pub allow_record: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub radio_info: Option, + pub lat_lng_position: Position, +} + +#[derive(Debug, PartialEq, Default, Clone, Serialize, Deserialize)] +pub struct Position { + pub lat: f64, + #[serde(rename = "lng")] + pub lon: f64, + pub alt: f64, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Transponder { + control: IffControlMode, + mode1: i32, + mode3: i32, + mode4: bool, + mic: i32, + status: IffStatus, +} + +#[derive(Debug, Serialize_repr, Deserialize_repr, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum IffControlMode { + Cockpit = 0, + Overlay = 1, + Disabled = 2, +} + +#[derive(Debug, Serialize_repr, Deserialize_repr, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum IffStatus { + Off = 0, + Normal = 1, + Ident = 2, +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub struct MsgType; + +#[derive(Debug)] +struct MsgTypeError; +impl Error for MsgTypeError {} +impl Display for MsgTypeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Invalid message type") + } +} + +impl Serialize for MsgType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_u8(V) + } +} + +impl<'de, const V: u8> Deserialize<'de> for MsgType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = u8::deserialize(deserializer)?; + if value == V { + Ok(MsgType::) + } else { + Err(serde::de::Error::custom(MsgTypeError)) + } + } +} + +impl Default for Transponder { + fn default() -> Self { + Transponder { + control: IffControlMode::Disabled, + mode1: -1, + mode3: -1, + mode4: false, + mic: -1, + status: IffStatus::Off, + } + } +} + +impl ::serde::Serialize for Coalition { + fn serialize(&self, serializer: S) -> Result + where + S: ::serde::Serializer, + { + // Serialize the enum as a u64. + serializer.serialize_u64(match *self { + Coalition::Spectator => 0, + Coalition::Red => 1, + Coalition::Blue => 2, + }) + } +} + +impl<'de> ::serde::Deserialize<'de> for Coalition { + fn deserialize(deserializer: D) -> Result + where + D: ::serde::Deserializer<'de>, + { + struct Visitor; + + impl<'de> ::serde::de::Visitor<'de> for Visitor { + type Value = Coalition; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("positive integer or string") + } + + fn visit_u64(self, value: u64) -> Result + where + E: ::serde::de::Error, + { + // Rust does not come with a simple way of converting a + // number to an enum, so use a big `match`. + match value { + 0 => Ok(Coalition::Spectator), + 1 => Ok(Coalition::Red), + 2 => Ok(Coalition::Blue), + _ => Err(E::custom(format!( + "unknown {} value: {}", + stringify!(Coalition), + value + ))), + } + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + let value = value.to_lowercase().clone(); + let value = value.as_str(); + + match value { + "spectator" => Ok(Coalition::Spectator), + "red" => Ok(Coalition::Red), + "blue" => Ok(Coalition::Blue), + _ => Err(E::custom(format!( + "unknown {} value: {}", + stringify!(Coalition), + value + ))), + } + } + } + + // Deserialize the enum from a u64. + deserializer.deserialize_u64(Visitor) + } +} + +pub fn create_sguid() -> String { + let sguid = Uuid::new_v4(); + // let sguid = base64::encode_config(sguid.as_bytes(), base64::URL_SAFE_NO_PAD); + + let sguid = BASE64.encode(sguid.as_bytes()); + + assert_eq!(sguid.len(), 22); + sguid +} diff --git a/crates/guardian_core/src/srs/messages_codec.rs b/crates/guardian_core/src/srs/messages_codec.rs new file mode 100644 index 0000000..06536d9 --- /dev/null +++ b/crates/guardian_core/src/srs/messages_codec.rs @@ -0,0 +1,97 @@ +use std::{error, fmt, io}; + +use bytes::BytesMut; +use tokio_util::codec::{Decoder, Encoder, LinesCodec, LinesCodecError}; + +use super::message::{Message, MessageRequest}; + +pub struct MessagesCodec { + lines_codec: LinesCodec, +} + +impl MessagesCodec { + pub fn new() -> Self { + MessagesCodec { + lines_codec: LinesCodec::new(), + } + } +} + +impl Decoder for MessagesCodec { + type Item = Message; + type Error = MessagesCodecError; + + fn decode(&mut self, buf: &mut BytesMut) -> Result, Self::Error> { + if let Some(line) = self.lines_codec.decode(buf)? { + match serde_json::from_str(&line) { + Ok(msg) => Ok(Some(msg)), + Err(err) => Err(MessagesCodecError::JsonDecode(err, line)), + } + } else { + Ok(None) + } + } + + fn decode_eof(&mut self, buf: &mut BytesMut) -> Result, Self::Error> { + if let Some(line) = self.lines_codec.decode_eof(buf)? { + match serde_json::from_str(&line) { + Ok(msg) => Ok(Some(msg)), + Err(err) => Err(MessagesCodecError::JsonDecode(err, line)), + } + } else { + Ok(None) + } + } +} + +impl Encoder for MessagesCodec { + type Error = MessagesCodecError; + + fn encode(&mut self, msg: MessageRequest, buf: &mut BytesMut) -> Result<(), Self::Error> { + let json = serde_json::to_string(&msg).map_err(MessagesCodecError::JsonEncode)?; + self.lines_codec.encode(json, buf)?; + Ok(()) + } +} + +#[derive(Debug)] +pub enum MessagesCodecError { + JsonDecode(serde_json::Error, String), + JsonEncode(serde_json::Error), + LinesCodec(LinesCodecError), + Io(io::Error), +} + +impl fmt::Display for MessagesCodecError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + MessagesCodecError::JsonDecode(_, json) => write!(f, "failed to decode JSON: {json}"), + MessagesCodecError::JsonEncode(_) => write!(f, "failed to encode JSON"), + MessagesCodecError::LinesCodec(err) => err.fmt(f), + MessagesCodecError::Io(err) => err.fmt(f), + } + } +} + +impl error::Error for MessagesCodecError { + fn source(&self) -> Option<&(dyn error::Error + 'static)> { + match self { + MessagesCodecError::JsonDecode(ref err, _) => Some(err), + MessagesCodecError::JsonEncode(ref err) => Some(err), + MessagesCodecError::LinesCodec(ref err) => Some(err), + MessagesCodecError::Io(ref err) => Some(err), + } + } +} + +impl From for MessagesCodecError { + fn from(err: io::Error) -> Self { + MessagesCodecError::Io(err) + } +} + +impl From for MessagesCodecError { + fn from(err: LinesCodecError) -> Self { + MessagesCodecError::LinesCodec(err) + } +} diff --git a/crates/guardian_core/src/dcs/voice.rs b/crates/guardian_core/src/srs/voice.rs similarity index 90% rename from crates/guardian_core/src/dcs/voice.rs rename to crates/guardian_core/src/srs/voice.rs index 60a3c88..a924108 100644 --- a/crates/guardian_core/src/dcs/voice.rs +++ b/crates/guardian_core/src/srs/voice.rs @@ -4,24 +4,11 @@ use std::{ }; use bevy::{ - app::Plugin, ecs::{component::Component, reflect::ReflectComponent}, reflect::{std_traits::ReflectDefault, Reflect}, utils::AHasher, }; -pub struct SrsPlugin; - -impl Plugin for SrsPlugin { - fn build(&self, app: &mut bevy::prelude::App) { - todo!() - } -} - -fn connect_srs() { - // will emit strings as commands -} - #[derive(Reflect, Component, Clone)] #[reflect(Component, Default, Debug)] pub struct VoiceMessage { @@ -35,6 +22,7 @@ impl Default for VoiceMessage { } } +#[allow(unused)] impl VoiceMessage { /// Creates a new [`VoiceMessage`] from any string-like type. /// diff --git a/crates/guardian_core/src/srs/voice_codec.rs b/crates/guardian_core/src/srs/voice_codec.rs new file mode 100644 index 0000000..209591a --- /dev/null +++ b/crates/guardian_core/src/srs/voice_codec.rs @@ -0,0 +1,308 @@ +use std::{ + io::{self, Cursor, Read, Write}, + mem::size_of, +}; + +use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt}; +use bytes::{BufMut, BytesMut}; +use tokio_util::codec::{Decoder, Encoder, LengthDelimitedCodec}; + +pub const GUID_LENGTH: usize = 22; + +pub const PACKET_HEADER_LENGTH: usize = size_of::() // UInt16 Packet Length - 2 bytes + + size_of::() // UInt16 AudioPart1 Length - 2 bytes + + size_of::(); // UInt16 FrequencyPart Length - 2 bytes + +pub const FREQUENCY_SEGMENT_LENGTH: usize = size_of::() // double Frequency - 8 bytes + + size_of::() // byte Modulation - 1 byte + + size_of::(); // byte Encryption - 1 byte + +pub const FIXED_PACKET_LENGTH: usize = size_of::() // UInt UnitId - 4 bytes + + size_of::() // UInt64 PacketId - 8 bytes + + size_of::() // Byte indicating number of hops for this message // default is 0 + + GUID_LENGTH // Bytes / ASCII String Transmission GUID - 22 bytes + + GUID_LENGTH; // Bytes / ASCII String GUID - 22 bytes + +pub const PACKET_NON_AUDIO_DATA_SIZE: usize = + PACKET_HEADER_LENGTH + FIXED_PACKET_LENGTH + FREQUENCY_SEGMENT_LENGTH; + +pub struct VoiceCodec { + inner: LengthDelimitedCodec, + is_head: bool, +} + +impl VoiceCodec { + pub fn new() -> Self { + VoiceCodec { + inner: LengthDelimitedCodec::builder() + .length_field_offset(0) + .length_field_length(2) + .length_adjustment(-2) + .little_endian() + .new_codec(), + is_head: true, + } + } +} + +#[derive(Debug, Clone)] +pub enum Modulation { + Am, + Fm, + Intercom, + Disabled, +} + +#[derive(Debug, Clone)] +pub enum Encryption { + None, + JustOverlay, + Full, + CockpitToggleOverlayCode, +} + +#[derive(Debug, Clone)] +pub struct Frequency { + pub freq: f64, + pub modulation: Modulation, + pub encryption: Encryption, +} + +#[derive(Debug)] +pub enum Packet { + Ping([u8; 22]), + Voice(VoicePacket), +} + +#[derive(Debug)] +pub struct VoicePacket { + // TODO: use Bytes instead? + pub audio_part: Vec, + pub wav_audio_part: Option>, + pub frequencies: Vec, + pub unit_id: u32, + pub packet_id: u64, + pub hop_count: u8, + pub transmission_sguid: [u8; 22], + pub client_sguid: [u8; 22], +} + +impl Decoder for VoiceCodec { + type Item = VoicePacket; + type Error = io::Error; + + fn decode(&mut self, buf: &mut BytesMut) -> Result, Self::Error> { + // discard ping messages + if self.is_head && buf.len() <= 22 { + return Ok(None); + } + + if buf.len() < PACKET_NON_AUDIO_DATA_SIZE { + return Ok(None); + } + + if let Some(bytes) = self.inner.decode(buf)? { + self.is_head = true; + + let len = bytes.len() as u64; + let mut rd = Cursor::new(bytes); + + let len_audio_part = rd.read_u16::()? as u64; + let len_frequencies = rd.read_u16::()? as u64; + + assert_eq!( + len, + 4 + len_audio_part + len_frequencies + 4 + 8 + 1 + 22 + 22 + ); + + let mut audio_part = vec![0u8; len_audio_part as usize]; + rd.read_exact(&mut audio_part)?; + + let wav_audio_part = opus_to_wav(&audio_part).unwrap(); + + let len_before = rd.position(); + let mut frequencies = Vec::new(); + while rd.position() - len_before < len_frequencies { + let freq = rd.read_f64::()?; + let modulation = match rd.read_u8()? { + 0 => Modulation::Am, + 1 => Modulation::Fm, + 2 => Modulation::Intercom, + 3 => Modulation::Disabled, + _ => Modulation::Am, + }; + let encryption = match rd.read_u8()? { + 0 => Encryption::None, + 1 => Encryption::JustOverlay, + 2 => Encryption::Full, + 3 => Encryption::CockpitToggleOverlayCode, + _ => Encryption::None, + }; + frequencies.push(Frequency { + freq, + modulation, + encryption, + }); + } + + let unit_id = rd.read_u32::()?; + let packet_id = rd.read_u64::()?; + let hop_count = rd.read_u8()?; + + let mut transmission_sguid = [0; 22]; + rd.read_exact(&mut transmission_sguid)?; + + let mut client_sguid = [0; 22]; + rd.read_exact(&mut client_sguid)?; + + assert_eq!(rd.position(), len); + + Ok(Some(VoicePacket { + audio_part, + wav_audio_part: Some(wav_audio_part), + frequencies, + unit_id, + packet_id, + hop_count, + transmission_sguid, + client_sguid, + })) + } else { + self.is_head = false; + Ok(None) + } + } + + fn decode_eof(&mut self, buf: &mut BytesMut) -> Result, Self::Error> { + self.decode(buf) + } +} + +impl Encoder for VoiceCodec { + type Error = io::Error; + + fn encode(&mut self, packet: Packet, buf: &mut BytesMut) -> Result<(), Self::Error> { + let packet = match packet { + Packet::Ping(sguid) => { + buf.put_slice(&sguid); + return Ok(()); + } + Packet::Voice(packet) => packet, + }; + + // Packet format as specified in + // https://github.com/ciribob/DCS-SimpleRadioStandalone/blob/1.9.3.0/DCS-SR-Common/Network/UDPVoicePacket.cs#L9 + /* + * UDP PACKET LAYOUT + * + * - HEADER SEGMENT + * UInt16 Packet Length - 2 bytes + * UInt16 AudioPart1 Length - 2 bytes + * UInt16 FrequencyPart Length - 2 bytes + * - AUDIO SEGMENT + * Bytes AudioPart1 - variable bytes + * - FREQUENCY SEGMENT (one or multiple) + * double Frequency - 8 bytes + * byte Modulation - 1 byte + * byte Encryption - 1 byte + * - FIXED SEGMENT + * UInt UnitId - 4 bytes + * UInt64 PacketId - 8 bytes + * byte Retransmit / node / hop count - 1 byte + * Bytes / ASCII String TRANSMISSION GUID - 22 bytes used for transmission relay + * Bytes / ASCII String CLIENT GUID - 22 bytes + */ + + // NOTE: the final packet will start with the total packet length, but this will be added + // by the inner fixed codec + let header_length = 2 + 2; + let frequency_length = 8 + 1 + 1; + let audio_length = packet.audio_part.len(); + let fixed_segment_length = 4 + 8 + 1 + 22 + 22; + + let capacity = header_length + + audio_length + + frequency_length * packet.frequencies.len() + + fixed_segment_length; + let mut wd = Cursor::new(Vec::with_capacity(capacity)); + + // header segment will be written at the end + wd.set_position(4); + + // - AUDIO SEGMENT + let len_before = wd.position(); + wd.write_all(&packet.audio_part)?; + let len_audio_part = wd.position() - len_before; + + // - FREQUENCY SEGMENT + let len_before = wd.position(); + for f in packet.frequencies { + wd.write_f64::(f.freq)?; + + wd.write_u8(match f.modulation { + Modulation::Am => 0, + Modulation::Fm => 1, + Modulation::Intercom => 2, + Modulation::Disabled => 3, + })?; + wd.write_u8(match f.encryption { + Encryption::None => 0, + Encryption::JustOverlay => 1, + Encryption::Full => 2, + Encryption::CockpitToggleOverlayCode => 3, + })?; + } + + let len_frequency = wd.position() - len_before; + + // - FIXED SEGMENT + wd.write_u32::(packet.unit_id)?; + wd.write_u64::(packet.packet_id)?; + wd.write_u8(packet.hop_count)?; // retransmission hop count + wd.write_all(&packet.transmission_sguid)?; // transmission guid + wd.write_all(&packet.client_sguid)?; // client guid + + // - HEADER SEGMENT + wd.set_position(0); + + // Packet Length: + // the final packet will start with the total packet length, but this will be added by + // the inner fixed codec + + // AudioPart1 Length + wd.write_u16::(len_audio_part as u16)?; + // FrequencyPart Length + wd.write_u16::(len_frequency as u16)?; + + let frame = wd.into_inner(); + assert_eq!(frame.len(), capacity); + + self.inner.encode(frame.into(), buf) + } +} + +impl From for Packet { + fn from(p: VoicePacket) -> Self { + Packet::Voice(p) + } +} + +const INPUT_SAMPLE_RATE: usize = 16000; +const INPUT_AUDIO_LENGTH: usize = 20; //ms +const FRAME_SIZE: usize = INPUT_SAMPLE_RATE / 1000 * INPUT_AUDIO_LENGTH; + +pub(crate) fn opus_to_wav(opus: &[u8]) -> Result, Box> { + use audiopus::coder::Decoder; + use audiopus::packet::Packet; + use audiopus::{Channels, MutSignals, SampleRate}; + + let packet: Packet = opus.try_into()?; + + let mut pcm_audio_short = vec![0i16; FRAME_SIZE * 2]; //[0i16; FRAME_SIZE]; + let signals = MutSignals::try_from(&mut pcm_audio_short)?; + + let mut decoder = Decoder::new(SampleRate::Hz16000, Channels::Mono)?; + decoder.decode(Some(packet), signals, false)?; // BadArgument, huh?! + + Ok(pcm_audio_short) +} diff --git a/crates/guardian_core/src/srs/voice_command.rs b/crates/guardian_core/src/srs/voice_command.rs new file mode 100644 index 0000000..3f959e4 --- /dev/null +++ b/crates/guardian_core/src/srs/voice_command.rs @@ -0,0 +1,81 @@ +use std::{ + borrow::Cow, + hash::{Hash, Hasher}, +}; + +use bevy::{ + ecs::{component::Component, entity::Entity}, + utils::AHasher, +}; + +#[derive(Component, Clone)] +pub struct VoiceCommand { + hash: u64, + message: Cow<'static, str>, + entity: Entity, +} + +#[allow(unused)] +impl VoiceCommand { + /// Creates a new [`VoiceCommand`] from any string-like type. + /// + /// The internal hash will be computed immediately. + pub fn new(message: impl Into>, entity: Entity) -> Self { + let message = message.into(); + let mut message = VoiceCommand { + message, + hash: 0, + entity, + }; + message.update_hash(); + message + } + + /// Sets the entity's message. + /// + /// The internal hash will be re-computed. + #[inline(always)] + pub fn set(&mut self, message: impl Into>) { + *self = VoiceCommand::new(message, self.entity); + } + + pub fn get_entity(&self) -> &Entity { + &self.entity + } + + /// Updates the message of the entity in place. + /// + /// This will allocate a new string if the message was previously + /// created from a borrow. + #[inline(always)] + pub fn mutate(&mut self, f: F) { + f(self.message.to_mut()); + self.update_hash(); + } + + /// Gets the message of the entity as a `&str`. + #[inline(always)] + pub fn as_str(&self) -> &str { + &self.message + } + + fn update_hash(&mut self) { + let mut hasher = AHasher::default(); + self.message.hash(&mut hasher); + self.hash = hasher.finish(); + } +} + +impl std::fmt::Display for VoiceCommand { + #[inline(always)] + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + std::fmt::Display::fmt(&self.message, f) + } +} + +impl std::fmt::Debug for VoiceCommand { + #[inline(always)] + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + std::fmt::Debug::fmt(&self.message, f) + } +} diff --git a/crates/guardian_core/src/tts.rs b/crates/guardian_core/src/tts.rs new file mode 100644 index 0000000..0864d8b --- /dev/null +++ b/crates/guardian_core/src/tts.rs @@ -0,0 +1,160 @@ +// TODO: Look into SpeakNG to avoid the windows dependency https://crates.io/crates/espeakng + +use std::borrow::Cow; + +use tokio::sync::Mutex; +use windows::core::HSTRING; +use windows::Media::SpeechSynthesis::SpeechSynthesizer; +use windows::Storage::Streams::DataReader; + +#[derive(Debug)] +pub struct WinConfig { + pub voice: Option, +} + +impl WinConfig { + pub fn new() -> Self { + let voice = "David"; // for now + Self { + voice: Some(voice.to_string()), + } + } +} + +static MUTEX: Mutex<()> = Mutex::const_new(()); + +pub async fn synthesize(text: &str) -> Result>, WinError> { + let config = WinConfig::new(); + // Note, there does not seem to be a way to explicitly set 16000kHz, 16 audio bits per + // sample and mono channel. + + // Prevent concurrent Windows TTS synthesis, as this might cause a crash. + let lock = MUTEX.lock().await; + + let mut voice_info = None; + if let Some(voice) = &config.voice { + let all_voices = SpeechSynthesizer::AllVoices()?; + let len = all_voices.Size()? as usize; + for i in 0..len { + let v = all_voices.GetAt(i as u32)?; + let lang = v.Language()?.to_string(); + if !lang.starts_with("en-") { + continue; + } + + let name = v.DisplayName()?.to_string(); + if name.ends_with(voice) { + voice_info = Some(v); + break; + } + } + } else { + // default to the first english voice in the list + let all_voices = SpeechSynthesizer::AllVoices()?; + let len = all_voices.Size()? as usize; + for i in 0..len { + let v = all_voices.GetAt(i as u32)?; + let lang = v.Language()?.to_string(); + if lang.starts_with("en-") { + let name = v.DisplayName()?.to_string(); + println!("Using WIN voice: {}", name); + voice_info = Some(v); + break; + } + } + + if voice_info.is_none() { + println!("Could not find any english Windows TTS voice"); + } + } + + if voice_info.is_none() { + let all_voices = SpeechSynthesizer::AllVoices()?; + let len = all_voices.Size()? as usize; + println!( + "Available WIN voices are (you don't have to include the `Microsoft` prefix in \ + the name):" + ); + for i in 0..len { + let v = all_voices.GetAt(i as u32)?; + let lang = v.Language()?.to_string(); + if !lang.starts_with("en-") { + continue; + } + + let name = v.DisplayName()?.to_string(); + println!("- {} ({})", name, lang); + } + } + + let synth = SpeechSynthesizer::new()?; + let lang = if let Some(info) = voice_info { + synth.SetVoice(&info)?; + info.Language()?.to_string().into() + } else { + Cow::Borrowed("en") + }; + + // the DataReader is !Send, which is why we have to process it in a local set + let stream = synth + .SynthesizeSsmlToStreamAsync(&HSTRING::from(&format!( + r#"{text}"# + )))? + .await?; + let size = stream.Size()?; + + let rd = DataReader::CreateDataReader(&stream.GetInputStreamAt(0)?)?; + rd.LoadAsync(size as u32)?.await?; + + let mut wav = vec![0u8; size as usize]; + rd.ReadBytes(wav.as_mut_slice())?; + + drop(lock); + + Ok(wav_to_opus(wav.into()).await?) +} + +#[derive(Debug, thiserror::Error)] +pub enum WinError { + #[error("Calling WinRT API failed with error code {0}: {1}")] + Win(i32, String), + #[error("Runtime error")] + Io(#[from] std::io::Error), + #[error("failed to encode audio data as opus")] + Opus(#[from] audiopus::Error), +} + +impl From for WinError { + fn from(err: windows::core::Error) -> Self { + WinError::Win(err.code().0, err.message().to_string()) + } +} + +async fn wav_to_opus(wav: bytes::Bytes) -> Result>, audiopus::Error> { + use audiopus::coder::Encoder; + use audiopus::{Application, Channels, SampleRate}; + + tokio::task::spawn_blocking(move || { + let audio_stream = wav + .chunks(2) + .map(|bytes| i16::from_le_bytes(bytes.try_into().unwrap())) + .collect::>(); + + const MONO_20MS: usize = 16000 /* 1 channel */ * 20 / 1000; + let enc = Encoder::new(SampleRate::Hz16000, Channels::Mono, Application::Voip)?; + let mut pos = 0; + let mut output = [0; 256]; + let mut frames = Vec::new(); + + while pos + MONO_20MS < audio_stream.len() { + let len = enc.encode(&audio_stream[pos..(pos + MONO_20MS)], &mut output)?; + frames.push(output[..len].to_vec()); + + pos += MONO_20MS; + } + + Ok::<_, audiopus::Error>(frames) + }) + .await + .unwrap() +} diff --git a/src/braa.rs b/src/braa.rs new file mode 100644 index 0000000..fca33e4 --- /dev/null +++ b/src/braa.rs @@ -0,0 +1,97 @@ +use std::fmt::Display; + +use guardian_core::components::{Heading, Position}; + +#[derive(Clone)] +pub enum Aspect { + Hot, + Flank, + Cold, +} + +impl std::fmt::Display for Aspect { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Hot => write!(f, "Hot"), + Self::Flank => write!(f, "Flank"), + Self::Cold => write!(f, "Cold"), + } + } +} + +#[derive(Clone)] +pub struct Braa { + bearing: i32, + range: i32, + angels: i32, + aspect: Aspect, +} + +impl Display for Braa { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.to_text()) + } +} + +#[allow(unused)] +impl Braa { + pub fn new(unit_a: &Position, unit_b: &Position, heading_b: &Heading) -> Self { + let bearing = unit_a.get_bearing_to(unit_b); + let range = unit_a.distance_to_nmi(unit_b).round() as i32; + let angels = unit_b.angels(); + let aspect = { + let bearing = unit_b.get_bearing_to(unit_a); + let angle = (bearing - heading_b.0 as i32) % 360; + if (45..135).contains(&angle) { + Aspect::Flank + } else if (135..225).contains(&angle) { + Aspect::Cold + } else if (225..315).contains(&angle) { + Aspect::Flank + } else if !(45..=315).contains(&angle) { + Aspect::Hot + } else { + Aspect::Cold + } + }; + + Self { + bearing, + range, + angels, + aspect, + } + } + + pub fn to_text(&self) -> String { + format!( + "BRA {}° for {}NM at {} thousand {}", + self.bearing, self.range, self.angels, self.aspect + ) + } + + pub fn to_voice(&self) -> String { + let bearing = split(&self.bearing.to_string(), 1); + let range = split(&self.range.to_string(), 1); + format!( + "BRA {} {} {} thousand {}", + bearing, range, self.angels, self.aspect + ) + } +} + +fn split(input: &str, n: usize) -> String { + input + .chars() + .enumerate() + .flat_map(|(i, c)| { + if i != 0 && i % n == 0 { + Some(' ') + } else { + None + } + .into_iter() + .chain(std::iter::once(c)) + }) + .collect::() +} diff --git a/src/commands/bogeydope.rs b/src/commands/bogeydope.rs new file mode 100644 index 0000000..6092258 --- /dev/null +++ b/src/commands/bogeydope.rs @@ -0,0 +1,85 @@ +use bevy::ecs::{ + query::{With, Without}, + system::{Commands, Query, Resource}, +}; +use clap::Parser; +use guardian_commands::{ConsoleCommand, NamedCommand}; +use guardian_core::{components::*, dcs::text::TextMessage, srs::voice::VoiceMessage}; + +use crate::braa::Braa; + +#[derive(Parser, Debug)] +pub struct BogeyDope; + +impl NamedCommand for BogeyDope { + fn name() -> &'static str { + "[shopping|bogey dope]" + } +} + +impl Resource for BogeyDope {} + +pub fn bogey_dope( + mut commands: Commands, + cmd: ConsoleCommand, + awacs: Query<&Callsign>, + player: Query<(&Callsign, &Position)>, + npc: Query<(&Position, &Heading), (Without, With)>, +) { + let Some(pilot) = &cmd.pilot else { + return; // no pilot + }; + let Some(operator) = &cmd.operator else { + return; + }; + + let Ok((p_callsign, p_pos)) = player.get(*pilot) else { + eprintln!("no player, died?"); + return; + }; + + let Ok(a_callsign) = awacs.get(*operator) else { + eprintln!("no awacs, they died?"); + return; + }; + + let mut distance: f64 = 0.0; + let mut n = (None, None); + for (n_pos, n_heading) in npc.iter() { + let d = p_pos.distance_to_nmi(n_pos); + if distance < d { + distance = d; + n = (Some(n_pos), Some(n_heading)); + } + } + + let (Some(n_position), Some(n_heading)) = n else { + let message = format!("{}, {}, {}", p_callsign, a_callsign, "Skies are clear"); + let voice_message = format!( + "{}, {}, {}", + p_callsign.to_voice(), + a_callsign.to_voice(), + "Skies are clear" + ); + + commands.spawn(TextMessage::new(message)); + commands + .entity(*operator) + .insert(VoiceMessage::new(voice_message)); + return; + }; + + let bra = Braa::new(p_pos, n_position, n_heading); + let message = format!("{}, {}, {}", p_callsign, a_callsign, bra); + let voice_message = format!( + "{}, {}, {}", + p_callsign.to_voice(), + a_callsign.to_voice(), + bra.to_voice() + ); + + commands.spawn(TextMessage::new(message)); + commands + .entity(*operator) + .insert(VoiceMessage::new(voice_message)); +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..d68f73e --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,21 @@ +use bevy::app::Plugin; +use guardian_commands::AddConsoleCommand; + +mod bogeydope; +mod radiocheck; +mod tripwire; + +use bogeydope::*; +use radiocheck::*; +use tripwire::*; + +pub struct CommandsPlugin; + +impl Plugin for CommandsPlugin { + fn build(&self, app: &mut bevy::prelude::App) { + app.add_plugins(guardian_commands::CommandsPlugin); + app.add_console_command::(bogey_dope); + app.add_console_command::(radio_check); + app.add_console_command::(set_tripwire); + } +} diff --git a/src/commands/radiocheck.rs b/src/commands/radiocheck.rs new file mode 100644 index 0000000..01107b6 --- /dev/null +++ b/src/commands/radiocheck.rs @@ -0,0 +1,51 @@ +use bevy::ecs::system::{Commands, Query, Resource}; +use clap::Parser; +use guardian_commands::{ConsoleCommand, NamedCommand}; +use guardian_core::{components::*, dcs::text::TextMessage, srs::voice::VoiceMessage}; + +#[derive(Parser, Debug)] +pub struct RadioCheck; + +impl NamedCommand for RadioCheck { + fn name() -> &'static str { + "radio check" + } +} + +impl Resource for RadioCheck {} + +pub fn radio_check( + mut commands: Commands, + cmd: ConsoleCommand, + callsign: Query<&Callsign>, +) { + let Some(pilot) = &cmd.pilot else { + return; // no pilot + }; + let Some(operator) = &cmd.operator else { + return; + }; + + let Ok(p_callsign) = callsign.get(*pilot) else { + eprintln!("no player, died?"); + return; + }; + + let Ok(a_callsign) = callsign.get(*operator) else { + eprintln!("no awacs, they died?"); + return; + }; + + let message = format!("{}, {}, {}", p_callsign, a_callsign, "five by five"); + let voice_message = format!( + "{}, {}, {}", + p_callsign.to_voice(), + a_callsign.to_voice(), + "five by five" + ); + + commands.spawn(TextMessage::new(message)); + commands + .entity(*operator) + .insert(VoiceMessage::new(voice_message)); +} diff --git a/src/commands/tripwire.rs b/src/commands/tripwire.rs new file mode 100644 index 0000000..93d49fc --- /dev/null +++ b/src/commands/tripwire.rs @@ -0,0 +1,102 @@ +use bevy::ecs::system::{Commands, Query, Resource}; +use clap::Parser; +use guardian_commands::{ConsoleCommand, NamedCommand}; +use guardian_core::{components::*, dcs::text::TextMessage, srs::voice::VoiceMessage}; + +use crate::tripwire::Tripwire; + +#[derive(Parser, Debug)] +pub struct TripwireCommand; + +impl NamedCommand for TripwireCommand { + fn name() -> &'static str { + "set [tripwire|warning]" + } +} + +impl Resource for TripwireCommand {} + +pub fn set_tripwire( + mut commands: Commands, + cmd: ConsoleCommand, + callsign: Query<&Callsign>, +) { + let Some(pilot) = &cmd.pilot else { + eprintln!("no pilot"); + return; // no pilot + }; + let Some(operator) = &cmd.operator else { + eprintln!("no operator"); + return; + }; + let Some(readback) = &cmd.raw else { + eprintln!("no readback"); + // no raw found, wut + return; + }; + + let Ok(p_callsign) = callsign.get(*pilot) else { + eprintln!("no player, died?"); + return; + }; + + let Ok(a_callsign) = callsign.get(*operator) else { + eprintln!("no awacs, they died?"); + return; + }; + + let mut parts = readback.split(' '); + + // check the last part, if it's a number, interpret as miles + let Some(last) = parts.nth_back(0) else { + eprintln!("no last part?"); + // there is no last.. dafuq? + // there is always a last, otherwise we wouldnt be here + return; // readback error + }; + + let distance = match last.parse::() { + Ok(distance) => distance, // a number is found, interpret as miles + Err(_) => { + let Some(maybe_number) = parts.nth_back(0) else { + // Report message garbled.. + return; + }; + + match maybe_number.parse::() { + Ok(n) => { + if last == "kilometers" || last == "km" { + (n as f64 * 0.621371).round() as i32 + } else { + n + } + } + Err(_) => { + // let message = format!("{}, {}. Unable to read, Say again.", caller, receiver); + return; + } + } + } + }; + + // clamp distance between 0 and 120 + let distance = distance.min(120).max(0); + + commands.entity(*pilot).insert(Tripwire::new(distance)); + + let message = format!( + "{}, {}, tripwire set to {} miles", + p_callsign, a_callsign, distance + ); + let message_voice = format!( + "{}, {}, tripwire set to {} miles", + p_callsign.to_voice(), + a_callsign.to_voice(), + distance + ); + + commands.spawn(TextMessage::new(message)); + commands + .entity(*operator) + .insert(VoiceMessage::new(message_voice)); +} diff --git a/src/main.rs b/src/main.rs index 5a4f9e4..71051c4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,158 +1,50 @@ +mod braa; +mod commands; +mod tripwire; + use bevy::{ app::{App, Update}, ecs::{ - component::Component, entity::Entity, - query::{Added, With, Without}, + query::{With, Without}, system::{Commands, Query}, }, }; -use guardian_core::*; +use commands::CommandsPlugin; +use guardian_core::{ + components::{Awacs, Callsign}, + srs::{Modulation, Radio}, + DefaultPlugins, TokioResource, +}; use tokio::runtime::Handle; - -// Every incomming (voice) message is an event -// Systems can handle the events, eg bogey dope, set tripwire or radio check -// voice responses; srs should be a resource? -// |- srs plugin adds a resource to use to send messages i guess -// |-- or spawn an entity with a message commands.spawn((VoiceMessage("Hello World"), TextMessage("Hello World!"))) -// |-- srs plugin will check if these components are Added or w/e and handle accordingly +use tripwire::TripwirePlugin; #[tokio::main] async fn main() { App::new() .insert_resource(TokioResource(Handle::current())) .add_plugins(DefaultPlugins) - .add_systems(Update, (add_tripwire, tripwire)) + .add_plugins(TripwirePlugin) + .add_plugins(CommandsPlugin) + .add_systems(Update, give_awacs_radio) .run(); } -// this will be on a tripwire command event (from srs), instead of Added, not everyone may want it; opt-in over opt-out -fn add_tripwire(mut commands: Commands, players: Query>) { - for ent in players.iter() { - commands.entity(ent).insert(Tripwire { - range: 30.0, - reported: vec![], - }); - } -} - -fn tripwire( +fn give_awacs_radio( mut commands: Commands, - mut players: Query<(&Id, &Callsign, &Position, &mut Tripwire), With>, - npc: Query<(&Id, &Position, &Heading, &Side), Without>, + units: Query<(Entity, &Callsign), (With, Without)>, ) { - for (p_id, p_callsign, p_position, mut p_tripwire) in players.iter_mut() { - let _p_id = &p_id.0; - let _p_callsign = &p_callsign.0; - for (n_id, n_position, n_heading, side) in npc.iter() { - if side.0 != Coalition::Red { - continue; - } + for (ent, callsign) in units.iter() { + if callsign.as_str() == "Overlord1-1" { + commands + .entity(ent) + .insert(Radio::new(251000000, Modulation::Am)); + } - let n_id = &n_id.0; - - let distance = n_position.distance_to_nmi(p_position); - if distance <= p_tripwire.range && !p_tripwire.reported.contains(n_id) { - p_tripwire.reported.push(*n_id); - - let bra = Braa::new(p_position, n_position, n_heading); - commands.spawn(TextMessage::new(bra.to_text())); - } else if distance > p_tripwire.range && p_tripwire.reported.contains(n_id) { - p_tripwire.reported.retain(|b| b != n_id); - } + if callsign.as_str() == "Magic1-1" { + commands + .entity(ent) + .insert(Radio::new(266000000, Modulation::Am)); } } } - -#[derive(Component)] -struct Tripwire { - range: f64, - reported: Vec, -} - -#[derive(Clone)] -pub enum Aspect { - Hot, - Flank, - Cold, -} - -impl std::fmt::Display for Aspect { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Hot => write!(f, "Hot"), - Self::Flank => write!(f, "Flank"), - Self::Cold => write!(f, "Cold"), - } - } -} - -#[derive(Clone)] -pub struct Braa { - bearing: i32, - range: i32, - angels: i32, - aspect: Aspect, -} - -impl Braa { - pub fn new(unit_a: &Position, unit_b: &Position, heading_b: &Heading) -> Self { - let bearing = unit_a.get_bearing_to(unit_b); - let range = unit_a.distance_to_nmi(unit_b).round() as i32; - let angels = unit_b.angels(); - let aspect = { - let bearing = unit_b.get_bearing_to(unit_a); - let angle = (bearing - heading_b.0 as i32) % 360; - if (45..135).contains(&angle) { - Aspect::Flank - } else if (135..225).contains(&angle) { - Aspect::Cold - } else if (225..315).contains(&angle) { - Aspect::Flank - } else if !(45..=315).contains(&angle) { - Aspect::Hot - } else { - Aspect::Cold - } - }; - - Self { - bearing, - range, - angels, - aspect, - } - } - - pub fn to_text(&self) -> String { - format!( - "BRA {}° for {}NM at {} thousand {}", - self.bearing, self.range, self.angels, self.aspect - ) - } - - pub fn to_voice(&self) -> String { - let bearing = split(&self.bearing.to_string(), 1); - let range = split(&self.range.to_string(), 1); - format!( - "BRA {} {} {} thousand {}", - bearing, range, self.angels, self.aspect - ) - } -} - -fn split(input: &str, n: usize) -> String { - input - .chars() - .enumerate() - .flat_map(|(i, c)| { - if i != 0 && i % n == 0 { - Some(' ') - } else { - None - } - .into_iter() - .chain(std::iter::once(c)) - }) - .collect::() -} diff --git a/src/tripwire.rs b/src/tripwire.rs new file mode 100644 index 0000000..dcc5468 --- /dev/null +++ b/src/tripwire.rs @@ -0,0 +1,96 @@ +use bevy::{ + app::{Plugin, Update}, + ecs::{ + component::Component, + entity::Entity, + query::{Added, With, Without}, + system::{Commands, Query}, + }, +}; +use guardian_core::{ + components::*, + dcs::text::TextMessage, + srs::{voice::VoiceMessage, Radio, RadioInfo}, +}; + +use crate::braa::Braa; + +pub struct TripwirePlugin; + +impl Plugin for TripwirePlugin { + fn build(&self, app: &mut bevy::prelude::App) { + app.add_systems(Update, tripwire); + } +} + +// this will be on a tripwire command event (from srs), instead of Added, not everyone may want it; opt-in over opt-out +// fn add_tripwire(mut commands: Commands, players: Query>) { +// for ent in players.iter() { +// commands.entity(ent).insert(Tripwire { +// range: 2.0, +// reported: vec![], +// }); +// } +// } + +fn tripwire( + mut commands: Commands, + awacs: Query<(Entity, &Callsign, &Radio), (With, With, With)>, + mut players: Query<(&Callsign, &Position, &RadioInfo, &mut Tripwire), With>, + npc: Query<(&Id, &Position, &Heading), (Without, With)>, +) { + // get the players radio frequencies + // match the tuned frequency with one of the awacs' + // broadcast message on that ferquency and as that awacs + + for (ent, awacs, radio) in awacs.iter() { + for (p_callsign, p_position, radioinfo, mut p_tripwire) in players.iter_mut() { + // check if user is on the same frequency as awacs + if !radioinfo + .radios + .iter() + .any(|f| f.freq as u64 == radio.frequency && f.modulation == radio.modulation) + { + continue; + } + + for (n_id, n_position, n_heading) in npc.iter() { + let distance = n_position.distance_to_nmi(p_position); + if distance <= p_tripwire.range && !p_tripwire.reported.contains(n_id) { + p_tripwire.reported.push(*n_id); + + let bra = Braa::new(p_position, n_position, n_heading); + let message = format!("{}, {} {}", p_callsign, awacs, bra); + let voice_message = format!( + "{}, {}, {}", + p_callsign.to_voice(), + awacs.to_voice(), + bra.to_voice() + ); + + commands.spawn(TextMessage::new(message)); + commands + .entity(ent) + .insert(VoiceMessage::new(voice_message)); + } else if distance > p_tripwire.range && p_tripwire.reported.contains(n_id) { + p_tripwire.reported.retain(|b| b != n_id); + } + } + } + } +} + +#[derive(Debug, Component)] +pub struct Tripwire { + range: f64, + reported: Vec, +} + +impl Tripwire { + pub fn new(distance: i32) -> Self { + Self { + range: distance as f64, + reported: vec![], + } + } +}