commit 97d5561e811da0e9e47d9335aaf4dc4db1967534 Author: Martin Michaelis Date: Fri Jun 7 13:33:27 2024 +0000 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..214aa5f --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target +/*.toml +!/Cargo.toml +!/rustfmt.toml diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..cf2b278 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,610 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "anstream" +version = "0.6.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418c75fa768af9c03be99d17643f93f79bbba589895012a80e3452a19ddda15b" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "038dfcf04a5feb68e9c60b21c9625a54c2c0616e79b72b0fd87075a056ae1d1b" + +[[package]] +name = "anstyle-parse" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03a11a9034d92058ceb6ee011ce58af4a9bf61491aa7e1e59ecd24bd40d22d4" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad186efb764318d35165f1758e7dcef3b10628e26d41a44bc5550652e6804391" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61a38449feb7068f52bb06c12759005cf459ee52bb4adc1d5a7c4322d716fb19" +dependencies = [ + "anstyle", + "windows-sys 0.52.0", +] + +[[package]] +name = "anyhow" +version = "1.0.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" + +[[package]] +name = "autocfg" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" + +[[package]] +name = "az" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b7e4c2464d97fe331d41de9d5db0def0a96f4d823b8b32a2efd503578988973" + +[[package]] +name = "badgemagic" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64", + "clap", + "embedded-graphics", + "hidapi", + "serde", + "serde_json", + "time", + "toml", + "zerocopy", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cc" +version = "1.0.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41c270e7540d725e65ac7f1b212ac8ce349719624d7bcff99f8e2e488e8cf03f" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "clap" +version = "4.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bc066a67923782aa8515dbaea16946c5bcc5addbd668bb80af688e53e548a0" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae129e2e766ae0ec03484e609954119f123cc1fe650337e155d03b022f24f7b4" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528131438037fd55894f62d6e9f068b8f45ac57ffa77517819645d10aed04f64" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98cc8fbded0c607b7ba9dd60cd98df59af97e84d24e49c8557331cfc26d301ce" + +[[package]] +name = "colorchoice" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422" + +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "embedded-graphics" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0649998afacf6d575d126d83e68b78c0ab0e00ca2ac7e9b3db11b4cbe8274ef0" +dependencies = [ + "az", + "byteorder", + "embedded-graphics-core", + "float-cmp", + "micromath", +] + +[[package]] +name = "embedded-graphics-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba9ecd261f991856250d2207f6d8376946cd9f412a2165d3b75bc87a0bc7a044" +dependencies = [ + "az", + "byteorder", +] + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +dependencies = [ + "num-traits", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hidapi" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e58251020fe88fe0dae5ebcc1be92b4995214af84725b375d08354d0311c23c" +dependencies = [ + "cc", + "cfg-if", + "libc", + "pkg-config", + "windows-sys 0.48.0", +] + +[[package]] +name = "indexmap" +version = "2.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800" + +[[package]] +name = "itoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" + +[[package]] +name = "libc" +version = "0.2.155" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" + +[[package]] +name = "memchr" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d" + +[[package]] +name = "micromath" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3c8dda44ff03a2f238717214da50f65d5a53b45cd213a7370424ffdb6fae815" + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "pkg-config" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22244ce15aa966053a896d1accb3a6e68469b97c7f33f284b99f0d576879fc23" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "serde" +version = "1.0.203" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7253ab4de971e72fb7be983802300c30b5a7f0c2e56fab8abfc6a214307c0094" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.203" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "455182ea6142b14f93f4bc5320a2b31c1f266b66a4a5c858b013302a5d8cbfc3" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_spanned" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79e674e01f999af37c49f70a6ede167a8a60b2503e56c5599532a65baa5969a0" +dependencies = [ + "serde", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c42f3f41a2de00b01c0aaad383c5a45241efc8b2d1eda5661812fda5f3cdcff5" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "time" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "toml" +version = "0.8.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f49eb2ab21d2f26bd6db7bf383edc527a7ebaee412d17af4d40fdccd442f335" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4badfd56924ae69bcc9039335b2e017639ce3f9b001c393c1b2d1ef846ce2cbf" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f21c7aaf97f1bd9ca9d4f9e73b0a6c74bd5afef56f2bc931943a6e1c37e04e38" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "utf8parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.5", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb" +dependencies = [ + "windows_aarch64_gnullvm 0.52.5", + "windows_aarch64_msvc 0.52.5", + "windows_i686_gnu 0.52.5", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.5", + "windows_x86_64_gnu 0.52.5", + "windows_x86_64_gnullvm 0.52.5", + "windows_x86_64_msvc 0.52.5", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" + +[[package]] +name = "winnow" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41ff33f391015ecab21cd092389215eb265ef9496a9a07b6bee7d3529831deda" +dependencies = [ + "memchr", +] + +[[package]] +name = "zerocopy" +version = "0.7.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae87e3fcd617500e5d106f0380cf7b77f3c6092aae37191433159dda23cfb087" +dependencies = [ + "byteorder", + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15e934569e47891f7d9411f1a451d947a60e000ab3bd24fbb970f000387d1b3b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..f7ed9c6 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,48 @@ +[package] +name = "badgemagic" +version = "0.1.0" +authors = ["Martin Michaelis "] +edition = "2021" +description = "Badge Magic with LEDs - Library and CLI" +homepage = "https://badgemagic.fossasia.org" +repository = "https://github.com/fossasia/badgemagic-rs" +license = "MIT OR Apache-2.0" +publish = false + +[[bin]] +name = "badgemagic" +required-features = ["cli"] + +[[example]] +name = "hello-world" +required-features = ["embedded-graphics", "usb-hid"] + +[features] +default = ["embedded-graphics", "usb-hid"] + +cli = [ + "embedded-graphics", + "serde", + "usb-hid", + "dep:base64", + "dep:clap", + "dep:serde_json", + "dep:toml", +] + +embedded-graphics = ["dep:embedded-graphics"] +serde = ["dep:serde"] +usb-hid = ["dep:hidapi"] + +[dependencies] +anyhow = "1.0.86" +base64 = { version = "0.22.1", optional = true } +clap = { version = "4.5.4", features = ["derive"], optional = true } +embedded-graphics = { version = "0.8.1", optional = true } +hidapi = { version = "2.6.1", optional = true } +serde = { version = "1.0.203", features = ["derive"], optional = true } +serde_json = { version = "1.0.117", optional = true } +time = "0.3.36" +toml = { version = "0.8.13", optional = true } +zerocopy = { version = "0.7.34", features = ["derive"] } + diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 0000000..d9a10c0 --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..dd968bb --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,19 @@ +Copyright 2024 Martin Michaelis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a69902a --- /dev/null +++ b/README.md @@ -0,0 +1,128 @@ +[![CI](../../actions/workflows/ci.yaml/badge.svg)](../../actions/workflows/ci.yaml) + +# Badge Magic in Rust + +Library and CLI to configure LED badges. + +## Installation + +As of now there are no proper releases (with version numbers) of this tool. + +The latest commit on the main branch just gets build and released automatically. + +Download the prebuild program for one of the following operating systems: + +- [Linux (GNU / 64 bit)](../../releases/latest/download/badgemagic.x86_64-unknown-linux-gnu) +- [Windows (64 bit)](../../releases/latest/download/badgemagic.x86_64-pc-windows-msvc.exe) +- [MacOS (Intel)](../../releases/latest/download/badgemagic.x86_64-apple-darwin) +- [MacOS (M1, etc.)](../../releases/latest/download/badgemagic.aarch64-apple-darwin) + +```sh +# After the download rename the file to `badgemagic` +mv badgemagic. badgemagic + +# Make the program executable (linux / macOS only) +chmod +x badgemagic + +# Test that it works +./badgemagic --help +``` + +> Note: The windows and macOS build is not actively tested. Please try it out and report back whether it worked or any problems that might occour. + +If your system is not listed above (Linux / Windows on ARM, musl, etc.) or you want to do it anyway, you can install this tool from source: + +```sh +cargo install --git https://github.com/fossasia/badgemagic-rs --features cli +badgemagic --help +``` + +Or clone the repo and run the CLI: +```sh +git clone https://github.com/fossasia/badgemagic-rs +cd badgemagic-rs +cargo run --features cli -- --help +``` + +## Usage + +Execute the `badgemagic` tool and pass the file name of your configuration file. Depending on how you installed the tool: + +```sh +# Downloaded from release page +./badgemagic config.toml + +# Installed with cargo install +badgemagic config.toml + +# Run from git repository +cargo run --features cli -- config.toml +``` + +The above command will read your configuration from a file named `config.toml` in the current directory. + +## Configuration + +You can have a look at the example configurations in the [`demo` directory](demo). + +The TOML configuration consists of up to 8 message sections starting with `[[message]]`. + +Each message can have the following options: +```toml +[[message]] +# Enable blink mode +blink = true + +# Show a dotted border arround the display +border = true + +# Set the update speed of the animations (0 to 7) +speed = 6 + +# Set the display animation (left, right, up, down, center, fast, drop, curtain, laser) +mode = "left" + +# The text to show on the display +text = "Lorem ipsum dolor sit amet." +``` + +You can omit options you don't need: +```toml +[[message]] +mode = "center" +text = "Hello" +``` + +If you want you can "draw" images as ASCII art (`_` = Off, `X` = On): +```toml +[[message]] +mode = "center" +bitstring = """ +___XXXXX___ +__X_____X__ +_X_______X_ +X__XX_XX__X +X__XX_XX__X +X_________X +X_XX___XX_X +X__XXXXX__X +_X__XXX__X_ +__X_____X__ +___XXXXX___ +""" +``` + +You just replace the `text` option with `bitstring`. All other options (e.g. `border`, `blink`) still work and can be combined with a custom image. + +## License + +Licensed under either of + +- Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or https://www.apache.org/licenses/LICENSE-2.0) +- MIT license ([LICENSE-MIT](LICENSE-MIT) or https://opensource.org/licenses/MIT) + +at your option. + +### Contribution + +Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions. diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..413b504 --- /dev/null +++ b/build.rs @@ -0,0 +1,41 @@ +fn main() { + #[cfg(feature = "cli")] + cli::generate_version_info(); +} + +#[cfg(feature = "cli")] +mod cli { + use std::{env, fs, path::PathBuf, process::Command, str}; + + pub fn generate_version_info() { + let pkg_version = env::var("CARGO_PKG_VERSION").expect("missing package version"); + let git_version = git_version(); + let git_prefix = git_version + .is_some() + .then_some("commit-") + .unwrap_or_default(); + let git_version = git_version.as_deref().unwrap_or("unknown"); + let version = format!("{pkg_version}-git.{git_prefix}{git_version}"); + + let out: PathBuf = env::var_os("OUT_DIR").expect("build output path").into(); + fs::write( + out.join("cli.rs"), + format!("pub const VERSION: &str = {version:?};\n"), + ) + .expect("write cli.rs"); + } + + fn git_version() -> Option { + let output = Command::new("git") + .arg("describe") + .arg("--always") + .arg("--dirty=-modified") + .output() + .ok()?; + if output.status.success() { + Some(str::from_utf8(&output.stdout).ok()?.trim().into()) + } else { + None + } + } +} diff --git a/demo/effects.toml b/demo/effects.toml new file mode 100644 index 0000000..263294b --- /dev/null +++ b/demo/effects.toml @@ -0,0 +1,32 @@ +[[message]] +blink = true +border = true +mode = "center" +text = "Hello" + +[[message]] +mode = "laser" +text = "Laser" + +[[message]] +mode = "drop" +text = "Drop" + +[[message]] +mode = "curtain" +text = "Curtain" + +[[message]] +mode = "left" +text = "Move message left" + +[[message]] +mode = "up" +text = "Move up" + +[[message]] +blink = true +border = true +speed = 6 +mode = "left" +text = "Combine them all and add some speed... might be a bit overwhelming" diff --git a/demo/jump.toml b/demo/jump.toml new file mode 100644 index 0000000..41dff0a --- /dev/null +++ b/demo/jump.toml @@ -0,0 +1,16 @@ +[[message]] +speed = 6 +mode = "fast" +bitstring = """ +X__________________________________________X____X__________________________________________X____X___________________XXXX___________________X____X__________________________________________X +_X________________________________________X______X__________________XXXX__________________X______X_________________X____X_________________X______X__________________XXXX__________________X_ +__X_________________XXXX_________________X________X________________X____X________________X________X_____________X__X____X__X_____________X________X________________X____X________________X__ +___________________X____X__________________________________________X____X________________________________________X__XXXX__X________________________________________X____X___________________ +___________________X____X___________________________________________XXXX__________________________________________XXXXXXXX__________________________________________XXXX____________________ +____________________XXXX_______________________________________XXXXXXXXXXXXXX________________________________________X_________________________________________XXXXXXXXXXXXXX_______________ +__________________XXXXXXXX___________________________________________X______________________________________________X_X______________________________________________X______________________ +_________________X___X____X_________________________________________X_X____________________________________________X___X____________________________________________X_X_____________________ +__X_____________X___X_X____X_____________X________X________________X___X_________________X________X_______________X_____X________________X________X________________X___X_________________X__ +_X_________________X___X__________________X______X________________X_____X_________________X______X________________________________________X______X________________X_____X_________________X_ +X_________________X_____X__________________X____X__________________________________________X____X__________________________________________X____X__________________________________________X +""" diff --git a/demo/smiley.toml b/demo/smiley.toml new file mode 100644 index 0000000..67f99f3 --- /dev/null +++ b/demo/smiley.toml @@ -0,0 +1,15 @@ +[[message]] +mode = "center" +bitstring = """ +___XXXXX___ +__X_____X__ +_X_______X_ +X__XX_XX__X +X__XX_XX__X +X_________X +X_XX___XX_X +X__XXXXX__X +_X__XXX__X_ +__X_____X__ +___XXXXX___ +""" diff --git a/examples/hello-world.rs b/examples/hello-world.rs new file mode 100644 index 0000000..aa316b6 --- /dev/null +++ b/examples/hello-world.rs @@ -0,0 +1,51 @@ +#![warn(clippy::all, clippy::pedantic)] + +use anyhow::Result; +use badgemagic::{ + embedded_graphics::{ + geometry::Point, mono_font::MonoTextStyle, pixelcolor::BinaryColor, text::Text, + }, + protocol::{Mode, PayloadBuffer, Style}, + usb_hid::Device, + util::DrawableLayoutExt, +}; + +fn main() -> Result<()> { + let mut payload = PayloadBuffer::new(); + + payload.add_message_drawable( + Style::default().mode(Mode::Center), + &Text::new( + "Hello", + Point::new(0, 8), + MonoTextStyle::new( + &embedded_graphics::mono_font::iso_8859_1::FONT_6X9, + BinaryColor::On, + ), + ), + ); + + payload.add_message_drawable( + Style::default().mode(Mode::Center), + &Text::new( + "Hello", + Point::new(0, 5), + MonoTextStyle::new( + &embedded_graphics::mono_font::iso_8859_1::FONT_4X6, + BinaryColor::On, + ), + ) + .z_stack(Text::new( + "World", + Point::new(23, 8), + MonoTextStyle::new( + &embedded_graphics::mono_font::iso_8859_1::FONT_4X6, + BinaryColor::On, + ), + )), + ); + + Device::single()?.write(payload)?; + + Ok(()) +} diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..e3d0a76 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,3 @@ +format_code_in_doc_comments = true +imports_granularity = "Crate" +use_field_init_shorthand = true diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..70f174a --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,19 @@ +#![warn(clippy::all, clippy::pedantic)] +#![allow(clippy::missing_errors_doc)] + +pub mod protocol; + +#[cfg(feature = "usb-hid")] +pub mod usb_hid; + +#[cfg(feature = "embedded-graphics")] +pub mod util; + +#[cfg(feature = "embedded-graphics")] +pub use embedded_graphics; + +#[cfg(feature = "cli")] +#[doc(hidden)] +pub mod cli { + include!(concat!(env!("OUT_DIR"), "/cli.rs")); +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..f0fc347 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,183 @@ +#![warn(clippy::all, clippy::pedantic)] + +use std::{fs, path::PathBuf}; + +use anyhow::{Context, Result}; +use badgemagic::{ + protocol::{Mode, PayloadBuffer, Speed, Style}, + usb_hid::Device, +}; +use base64::Engine; +use clap::Parser; +use embedded_graphics::{ + geometry::Point, + image::{Image, ImageRawLE}, + mono_font::{iso_8859_1::FONT_6X9, MonoTextStyle}, + pixelcolor::BinaryColor, + text::Text, + Drawable, Pixel, +}; +use serde::Deserialize; + +#[derive(Parser)] +/// Upload a configuration with up to 8 messages to an LED badge +#[clap( + version = badgemagic::cli::VERSION, + author, + help_template = "\ +{before-help}{name} {version} +{author-with-newline} +{about-with-newline} +{usage-heading} {usage} + +{all-args}{after-help} + ", +)] +struct Args { + /// File format of the config file (toml, json) + #[clap(long)] + format: Option, + + /// Path to TOML configuration file + config: PathBuf, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Config { + #[serde(rename = "message")] + messages: Vec, +} + +#[derive(Deserialize)] +struct Message { + #[serde(default)] + blink: bool, + + #[serde(default)] + border: bool, + + #[serde(default)] + speed: Speed, + + #[serde(default)] + mode: Mode, + + #[serde(flatten)] + content: Content, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields, untagged)] +enum Content { + Text { text: String }, + Bitstring { bitstring: String }, + BitmapBase64 { width: u32, bitmap_base64: String }, + BitmapFile { width: u32, bitmap_file: PathBuf }, + // TODO: implement png + // PngFile { png_file: PathBuf }, +} + +fn main() -> Result<()> { + let args = Args::parse(); + let config = fs::read_to_string(&args.config) + .with_context(|| format!("load config: {:?}", args.config))?; + + let config: Config = { + let extension = args + .format + .as_deref() + .map(AsRef::as_ref) + .or(args.config.extension()) + .context("missing file extension for config file")?; + match extension.to_str().unwrap_or_default() { + "json" => serde_json::from_str(&config).context("parse config")?, + "toml" => toml::from_str(&config).context("parse config")?, + _ => anyhow::bail!("unsupported config file extension: {extension:?}"), + } + }; + + let mut payload = PayloadBuffer::new(); + + for message in config.messages { + let mut style = Style::default(); + if message.blink { + style = style.blink(); + } + if message.border { + style = style.border(); + } + style = style.speed(message.speed).mode(message.mode); + match message.content { + Content::Text { text } => { + let text = Text::new( + &text, + Point::new(0, 7), + MonoTextStyle::new(&FONT_6X9, BinaryColor::On), + ); + payload.add_message_drawable(style, &text); + } + Content::Bitstring { bitstring } => { + let lines: Vec<_> = bitstring.trim().lines().collect(); + + anyhow::ensure!( + lines.len() == 11, + "expected 11 lines in bitstring, found {} lines", + lines.len() + ); + let width = lines[0].len(); + if lines.iter().any(|l| l.len() != width) { + anyhow::bail!( + "lines should have the same length, got: {:?}", + lines.iter().map(|l| l.len()).collect::>() + ); + } + let mut buffer = payload.add_message(style, (width + 7) / 8); + + for (y, line) in lines.iter().enumerate() { + for (x, c) in line.chars().enumerate() { + match c { + '_' => { + // off + } + 'X' => { + Pixel( + Point::new(x.try_into().unwrap(), y.try_into().unwrap()), + BinaryColor::On, + ) + .draw(&mut buffer) + .unwrap(); + } + _ => anyhow::bail!("invalid bit value for bit ({x}, {y}): {c:?}"), + } + } + } + } + Content::BitmapBase64 { + width, + bitmap_base64: bitmap, + } => { + let data = if bitmap.ends_with('=') { + base64::engine::general_purpose::STANDARD + } else { + base64::engine::general_purpose::STANDARD_NO_PAD + } + .decode(bitmap) + .context("decode bitmap")?; + let image_raw = ImageRawLE::::new(&data, width); + let image = Image::new(&image_raw, Point::zero()); + payload.add_message_drawable(style, &image); + } + Content::BitmapFile { width, bitmap_file } => { + let data = fs::read(bitmap_file).context("load bitmap")?; + let image_raw = ImageRawLE::::new(&data, width); + let image = Image::new(&image_raw, Point::zero()); + payload.add_message_drawable(style, &image); + } + } + } + + Device::single()?.write(payload)?; + + Ok(()) +} diff --git a/src/protocol.rs b/src/protocol.rs new file mode 100644 index 0000000..788be7b --- /dev/null +++ b/src/protocol.rs @@ -0,0 +1,469 @@ +//! Protocol used to update the badge + +use std::{convert::Infallible, num::TryFromIntError}; + +#[cfg(feature = "embedded-graphics")] +use embedded_graphics::{ + draw_target::DrawTarget, + geometry::{Dimensions, Point, Size}, + pixelcolor::BinaryColor, + prelude::Pixel, + primitives::Rectangle, + Drawable, +}; +use time::OffsetDateTime; +use zerocopy::{AsBytes, BigEndian, FromBytes, FromZeroes, U16}; + +/// Message style configuration +/// ``` +/// use badgemagic::protocol::{Mode, Style}; +/// # ( +/// Style::default().blink().border().mode(Mode::Center) +/// # ); +/// ``` +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[must_use] +pub struct Style { + #[cfg_attr(feature = "serde", serde(default))] + blink: bool, + + #[cfg_attr(feature = "serde", serde(default))] + border: bool, + + #[cfg_attr(feature = "serde", serde(default))] + speed: Speed, + + #[cfg_attr(feature = "serde", serde(default))] + mode: Mode, +} + +impl Style { + /// Enable blink mode + /// + /// The message will blink. + /// ``` + /// use badgemagic::protocol::Style; + /// # ( + /// Style::default().blink() + /// # ); + /// ``` + pub fn blink(mut self) -> Self { + self.blink = true; + self + } + + /// Show a dotted border arround the display. + /// ``` + /// use badgemagic::protocol::Style; + /// # ( + /// Style::default().blink() + /// # ); + /// ``` + pub fn border(mut self) -> Self { + self.border = true; + self + } + + /// Set the update speed of the animations. + /// + /// The animation will jump to the next pixel at the specified frame rate. + /// ``` + /// use badgemagic::protocol::{Speed, Style}; + /// # ( + /// Style::default().speed(Speed::Fps1_2) + /// # ); + /// ``` + pub fn speed(mut self, speed: Speed) -> Self { + self.speed = speed; + self + } + + /// Set the display animation. + /// ``` + /// use badgemagic::protocol::{Mode, Style}; + /// # ( + /// Style::default().mode(Mode::Curtain) + /// # ); + /// ``` + /// + /// Show text centered, without an animation: + /// ``` + /// use badgemagic::protocol::{Mode, Style}; + /// # ( + /// Style::default().mode(Mode::Center) + /// # ); + /// ``` + pub fn mode(mut self, mode: Mode) -> Self { + self.mode = mode; + self + } +} + +/// Animation update speed +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(try_from = "u8", into = "u8"))] +pub enum Speed { + /// 1.2 FPS + Fps1_2, + + /// 1.3 FPS + Fps1_3, + + /// 2 FPS + Fps2, + + /// 2.4 FPS + Fps2_4, + + /// 2.8 FPS + #[default] + Fps2_8, + + /// 4.5 FPS + Fps4_5, + + /// 7.5 FPS + Fps7_5, + + /// 15 FPS + Fps15, +} + +impl From for u8 { + fn from(value: Speed) -> Self { + value as u8 + } +} + +impl TryFrom for Speed { + type Error = TryFromIntError; + + fn try_from(value: u8) -> Result { + Ok(match value { + 0 => Self::Fps1_2, + 1 => Self::Fps1_3, + 2 => Self::Fps2, + 3 => Self::Fps2_4, + 4 => Self::Fps2_8, + 5 => Self::Fps4_5, + 6 => Self::Fps7_5, + 7 => Self::Fps15, + _ => return Err(u8::try_from(-1).unwrap_err()), + }) + } +} + +/// Message display mode +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +pub enum Mode { + /// Scroll thorugh the message from left to right + #[default] + Left, + + /// Scroll through the message from right to left + Right, + + /// Enter from the bottom, move up + Up, + + /// Enter from the top, move down + Down, + + /// Center the text, no animation + Center, + + /// Fast mode for animations + /// + /// Will leave a 4 pixel gap between screens: + /// Place a 44x11 pixel screen every 48 pixels + Fast, + + /// Drop rows of pixels from the top + Drop, + + /// Open a curtain and reveal the message + Curtain, + + /// A laser will reveal the message from left to right + Laser, +} + +const MSG_PADDING_ALIGN: usize = 64; + +const MAGIC: [u8; 6] = *b"wang\0\0"; + +#[derive(FromZeroes, FromBytes, AsBytes)] +#[repr(C)] +struct Header { + magic: [u8; 6], + blink: u8, + border: u8, + speed_and_mode: [u8; 8], + message_length: [U16; 8], + _padding_1: [u8; 6], + timestamp: Timestamp, + _padding_2: [u8; 20], +} + +#[derive(FromZeroes, FromBytes, AsBytes)] +#[repr(C)] +struct Timestamp { + year: u8, + month: u8, + day: u8, + hour: u8, + minute: u8, + second: u8, +} + +impl Timestamp { + fn new(ts: OffsetDateTime) -> Self { + Self { + #[allow(clippy::cast_possible_truncation)] // clippy does not understand `rem_euclid(100) <= 100` + year: ts.year().rem_euclid(100) as u8, + month: ts.month() as u8, + day: ts.day(), + hour: ts.hour(), + minute: ts.minute(), + second: ts.second(), + } + } + + fn now() -> Self { + Self::new(OffsetDateTime::now_utc()) + } +} + +/// Buffer to create a payload +/// +/// A payload consits of up to 8 messages +/// ``` +/// # #[cfg(feature = "embedded-graphics")] +/// # fn main() { +/// # use badgemagic::protocol::{PayloadBuffer, Style}; +/// use badgemagic::embedded_graphics::{ +/// geometry::{Point, Size}, +/// pixelcolor::BinaryColor, +/// primitives::{PrimitiveStyle, Rectangle, Styled}, +/// }; +/// +/// let mut buffer = PayloadBuffer::new(); +/// buffer.add_message_drawable( +/// Style::default(), +/// &Styled::new( +/// Rectangle::new(Point::new(2, 2), Size::new(4, 7)), +/// PrimitiveStyle::with_fill(BinaryColor::On), +/// ), +/// ); +/// # } +/// # #[cfg(not(feature = "embedded-graphics"))] +/// # fn main() {} +/// ``` +pub struct PayloadBuffer { + num_messages: u8, + data: Vec, +} + +impl Default for PayloadBuffer { + fn default() -> Self { + Self::new() + } +} + +impl PayloadBuffer { + /// Create a new empty buffer + #[must_use] + pub fn new() -> Self { + Self { + num_messages: 0, + data: Header { + magic: MAGIC, + blink: 0, + border: 0, + speed_and_mode: [0; 8], + message_length: [0.into(); 8], + _padding_1: [0; 6], + timestamp: Timestamp::now(), + _padding_2: [0; 20], + } + .as_bytes() + .into(), + } + } + + fn header_mut(&mut self) -> &mut Header { + Header::mut_from_prefix(&mut self.data).unwrap() + } + + /// Return the current number of messages + pub fn num_messages(&mut self) -> usize { + self.num_messages as usize + } + + /// Add a messages containing the specified `content` + /// + /// ## Panics + /// This method panics if it is unable to draw the content. + #[cfg(feature = "embedded-graphics")] + pub fn add_message_drawable( + &mut self, + style: Style, + content: &(impl Drawable + Dimensions), + ) -> O { + #[allow(clippy::cast_possible_wrap)] + fn saturating_usize_to_isize(n: usize) -> isize { + usize::min(n, isize::MAX as usize) as isize + } + + fn add(a: i32, b: u32) -> usize { + let result = a as isize + saturating_usize_to_isize(b as usize); + result.try_into().unwrap_or_default() + } + + let bounds = content.bounding_box(); + let width = add(bounds.top_left.x, bounds.size.width); + let mut message = self.add_message(style, (width + 7) / 8); + content.draw(&mut message).unwrap() + } + + /// Add a message with `count * 8` columns + /// + /// The returned `MessageBuffer` can be used as an `embedded_graphics::DrawTarget` + /// with the `embedded_graphics` feature. + /// + /// ## Panics + /// Panics if the supported number of messages is reached. + pub fn add_message(&mut self, style: Style, count: usize) -> MessageBuffer { + let index = self.num_messages as usize; + assert!( + index < 8, + "maximum number of supported messages reached: {index} messages", + ); + self.num_messages += 1; + + let header = self.header_mut(); + + if style.blink { + header.blink |= 1 << index; + } + if style.border { + header.border |= 1 << index; + } + header.speed_and_mode[index] = ((style.speed as u8) << 4) | style.mode as u8; + header.message_length[index] = count.try_into().unwrap(); + + let start = self.data.len(); + self.data.resize(start + count * 11, 0); + MessageBuffer(FromBytes::mut_slice_from(&mut self.data[start..]).unwrap()) + } + + /// Get the current payload as bytes (without padding) + #[must_use] + pub fn as_bytes(&self) -> &[u8] { + &self.data + } + + /// Convert the payload buffe into bytes (with padding) + #[allow(clippy::missing_panics_doc)] // should never panic + #[must_use] + pub fn into_padded_bytes(self) -> impl AsRef<[u8]> { + let mut data = self.data; + + let prev_len = data.len(); + + // pad msg to align to 64 bytes + data.resize( + (data.len() + (MSG_PADDING_ALIGN - 1)) & !(MSG_PADDING_ALIGN - 1), + 0, + ); + + // validate alignment + assert_eq!(data.len() % 64, 0); + assert!(prev_len <= data.len()); + + data + } +} + +/// A display buffer for a single message. +/// +/// Can be used as an `embedded_graphics::DrawTarget`. +pub struct MessageBuffer<'a>(&'a mut [[u8; 11]]); + +impl MessageBuffer<'_> { + #[cfg(feature = "embedded-graphics")] + fn set(&mut self, point: Point, color: BinaryColor) -> Option<()> { + let byte = self + .0 + .get_mut(usize::try_from(point.x / 8).ok()?)? + .get_mut(usize::try_from(point.y).ok()?)?; + + let bit = 0x80 >> (point.x % 8); + match color { + BinaryColor::Off => { + *byte &= !bit; + } + BinaryColor::On => { + *byte |= bit; + } + } + Some(()) + } +} + +#[cfg(feature = "embedded-graphics")] +impl Dimensions for MessageBuffer<'_> { + fn bounding_box(&self) -> embedded_graphics::primitives::Rectangle { + Rectangle::new( + Point::zero(), + Size::new((self.0.len() * 8).try_into().unwrap(), 11), + ) + } +} + +#[cfg(feature = "embedded-graphics")] +impl DrawTarget for MessageBuffer<'_> { + type Color = BinaryColor; + + type Error = Infallible; + + fn draw_iter(&mut self, pixels: I) -> Result<(), Self::Error> + where + I: IntoIterator>, + { + for Pixel(point, color) in pixels { + #[allow(clippy::manual_assert)] + if self.set(point, color).is_none() { + panic!( + "tried to draw pixel outside the display area (x: {}, y: {})", + point.x, point.y + ); + } + } + Ok(()) + } +} + +#[cfg(test)] +mod test { + use std::ops::Range; + + use super::Speed; + + #[test] + fn speed_to_u8_and_back() { + const VALID_SPEED_VALUES: Range = 1..8; + for i in u8::MIN..u8::MAX { + if let Ok(speed) = Speed::try_from(i) { + assert_eq!(u8::from(speed), i); + } else { + assert!(!VALID_SPEED_VALUES.contains(&i)); + } + } + } +} diff --git a/src/usb_hid.rs b/src/usb_hid.rs new file mode 100644 index 0000000..55a822e --- /dev/null +++ b/src/usb_hid.rs @@ -0,0 +1,91 @@ +//! Connect to an LED badge via USB HID + +use std::sync::Arc; + +use anyhow::{Context, Result}; +use hidapi::{DeviceInfo, HidApi, HidDevice}; + +use crate::protocol::PayloadBuffer; + +enum DeviceType { + // rename if we add another device type + TheOnlyOneWeSupportForNow, +} + +impl DeviceType { + fn new(info: &DeviceInfo) -> Option { + Some(match (info.vendor_id(), info.product_id()) { + (0x0416, 0x5020) => Self::TheOnlyOneWeSupportForNow, + _ => return None, + }) + } +} + +/// A discovered USB device +pub struct Device { + api: Arc, + info: DeviceInfo, + type_: DeviceType, +} + +impl Device { + /// Return all supported devices + pub fn enumerate() -> Result> { + let api = HidApi::new().context("create hid api")?; + let api = Arc::new(api); + + let devices = api.device_list(); + let devices = devices + .filter_map(|info| { + DeviceType::new(info).map(|type_| Device { + api: api.clone(), + info: info.clone(), + type_, + }) + }) + .collect(); + + Ok(devices) + } + + /// Return the single supported device + /// + /// This function returns an error if no device could be found + /// or if multiple devices would match. + pub fn single() -> Result { + let mut devices = Self::enumerate()?.into_iter(); + let device = devices.next().context("no device found")?; + anyhow::ensure!(devices.next().is_none(), "multiple devices found"); + Ok(device) + } + + /// Write a payload to the device + pub fn write(&self, payload: PayloadBuffer) -> Result<()> { + let device = self.info.open_device(&self.api).context("open device")?; + match self.type_ { + DeviceType::TheOnlyOneWeSupportForNow => { + write_raw(&device, payload.into_padded_bytes().as_ref()) + } + } + } +} + +fn write_raw(device: &HidDevice, data: &[u8]) -> Result<()> { + anyhow::ensure!(data.len() % 64 == 0, "payload not padded to 64 bytes"); + + // the device will brick itself if the payload is too long (more then 8192 bytes) + anyhow::ensure!(data.len() <= 8192, "payload too long (max 8192 bytes)"); + + // just to be sure + assert!(data.len() <= 8192); + + let n = device.write(data).context("write payload")?; + + anyhow::ensure!( + n == data.len(), + "incomplete write: {n} of {} bytes", + data.len() + ); + + Ok(()) +} diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..695d051 --- /dev/null +++ b/src/util.rs @@ -0,0 +1,62 @@ +//! Graphics utilities + +use embedded_graphics::Drawable; + +use self::layout::ZStack; + +/// Drawable layout extension +pub trait DrawableLayoutExt: Drawable + Sized { + /// Draw a + fn z_stack(self, other: T) -> ZStack { + ZStack(self, other) + } +} + +impl DrawableLayoutExt for T where T: Drawable {} + +pub mod layout { + //! Types used by `DrawableLayoutExt ` + + use embedded_graphics::{ + geometry::{Dimensions, Point}, + primitives::Rectangle, + Drawable, + }; + + pub struct ZStack(pub(super) A, pub(super) B); + + impl Dimensions for ZStack + where + A: Dimensions, + B: Dimensions, + { + fn bounding_box(&self) -> Rectangle { + let a = self.0.bounding_box(); + let b = self.1.bounding_box(); + let left = i32::min(a.top_left.x, b.top_left.x); + let top = i32::min(a.top_left.y, b.top_left.y); + let right = i32::max(a.bottom_right().unwrap().x, b.bottom_right().unwrap().x); + let bottom = i32::max(a.bottom_right().unwrap().y, b.bottom_right().unwrap().y); + Rectangle::with_corners(Point::new(left, top), Point::new(right, bottom)) + } + } + + impl Drawable for ZStack + where + A: Drawable, + B: Drawable, + { + type Color = A::Color; + + type Output = (A::Output, B::Output); + + fn draw(&self, target: &mut D) -> std::prelude::v1::Result + where + D: embedded_graphics::prelude::DrawTarget, + { + let a = self.0.draw(target)?; + let b = self.1.draw(target)?; + Ok((a, b)) + } + } +}