commit 427856cd3a020665a2e8457f287c50410831e9d9 Author: Beyhan Oğur Date: Sun Apr 26 22:29:38 2026 +0300 first commit diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..297f02e --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +ko_fi: chrivers diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..de3d037 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,140 @@ +name: Docker + +on: + push: + branches: [ "master", "dev" ] + # Publish semver tags as releases. + tags: [ "v*.*.*", "dev*" ] +# pull_request: +# branches: [ "master" ] + +env: + # Use docker.io for Docker Hub if empty + REGISTRY: ghcr.io + # github.repository as / + IMAGE_NAME: ${{ github.repository }} + + +jobs: + build: + strategy: + matrix: + include: + - name: amd64 + runner: ubuntu-24.04 + platform: linux/amd64 + - name: arm64 + runner: ubuntu-24.04-arm + platform: linux/arm64 + runs-on: ${{ matrix.runner }} + permissions: + contents: read + packages: write + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Set up BuildKit Docker container builder to be able to build + # multi-platform images and export cache + # https://github.com/docker/setup-buildx-action + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 + + # Login against a Docker registry except on PR + # https://github.com/docker/login-action + - name: Log into registry ${{ env.REGISTRY }} + if: github.event_name != 'pull_request' + uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Extract metadata (tags, labels) for Docker + # https://github.com/docker/metadata-action + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + # set latest tag for default branch + type=raw,value=latest,enable={{is_default_branch}} + type=raw,value={{branch}}-{{date 'YYYY-MM-DD'}} + + # Build and push Docker image with Buildx (don't push on PR) + # https://github.com/docker/build-push-action + - name: Build and push Docker image + id: build-and-push + uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=ci-${{ matrix.name }} + cache-to: type=gha,mode=max,scope=ci-${{ matrix.name }} + platforms: ${{ matrix.platform }} + outputs: push-by-digest=true + + - name: Export digest + run: | + mkdir -p ${{ runner.temp }}/digests + digest="${{ steps.build-and-push.outputs.digest }}" + touch "${{ runner.temp }}/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ matrix.name }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + merge: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + needs: + - build + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: ${{ runner.temp }}/digests + pattern: digests-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 + + - name: Log into registry ${{ env.REGISTRY }} + if: github.event_name != 'pull_request' + uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Extract metadata (tags, labels) for Docker + # https://github.com/docker/metadata-action + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + # set latest tag for default branch + type=raw,value=latest,enable={{is_default_branch}} + # maintain {{branch}}-latest tag for each branch + type=raw,value={{branch}}-latest + # set iso date tag per branch + type=raw,value={{branch}}-{{date 'YYYY-MM-DD'}} + + - name: Create manifest list and push + working-directory: ${{ runner.temp }}/digests + run: | + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..0ff6b61 --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,30 @@ +name: Rust + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Syntax check + run: cargo check --all-targets --workspace + + - name: Lint check + run: cargo clippy --all-targets --workspace + + - name: Run tests + run: cargo test --workspace + + - name: Run format check + run: cargo fmt --check --all diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1052184 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +/target +/cert.pem +/notes +/config.yaml +/state.yaml +/samples +/*.log +*~ +/data +/*.json diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..b99536b --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3288 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "ahash" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy 0.7.35", +] + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" + +[[package]] +name = "anstyle-parse" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" +dependencies = [ + "anstyle", + "once_cell", + "windows-sys 0.59.0", +] + +[[package]] +name = "anyhow" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04" + +[[package]] +name = "arc-swap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" + +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + +[[package]] +name = "async-trait" +version = "0.1.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "644dd749086bf3771a2fbc5f256fdb982d53f011c7d5d560304eafeecebce79d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "axum" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d6fd624c75e18b3b4c6b9caf42b1afe24437daaee904069137d8bab077be8b8" +dependencies = [ + "axum-core", + "axum-macros", + "base64", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "multer", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower 0.5.2", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1362f362fd16024ae199c1970ce98f9661bf5ef94b9808fee734bc3698b733" +dependencies = [ + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + +[[package]] +name = "axum-server" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56bac90848f6a9393ac03c63c640925c4b7c8ca21654de40d53f55964667c7d8" +dependencies = [ + "arc-swap", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "openssl", + "pin-project-lite", + "tokio", + "tokio-openssl", + "tower 0.4.13", + "tower-service", +] + +[[package]] +name = "backtrace" +version = "0.3.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets 0.52.6", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + +[[package]] +name = "bifrost" +version = "0.1.0" +dependencies = [ + "async-trait", + "axum", + "axum-core", + "axum-server", + "bifrost-api", + "bytes", + "camino", + "chrono", + "clap", + "clap-stdin", + "config", + "der", + "ecdsa", + "futures", + "hex", + "hue", + "hyper", + "iana-time-zone", + "itertools", + "json_diff_ng", + "log", + "mac_address", + "maplit", + "mdns-sd", + "mime", + "native-tls", + "nix 0.30.0", + "openssl", + "p256", + "packed_struct", + "pretty_env_logger", + "quick-xml", + "rand 0.9.0", + "reqwest", + "rsa", + "rustls-pemfile", + "serde", + "serde_json", + "serde_yml", + "sha1", + "sha2", + "svc", + "termcolor", + "thiserror 2.0.12", + "tokio", + "tokio-native-tls", + "tokio-openssl", + "tokio-ssdp", + "tokio-stream", + "tokio-tungstenite", + "tokio-util", + "tower 0.5.2", + "tower-http", + "tracing", + "tzfile", + "udp-stream", + "url", + "uuid", + "x509-cert", + "z2m", + "zcl", +] + +[[package]] +name = "bifrost-api" +version = "0.1.0" +dependencies = [ + "camino", + "hue", + "mac_address", + "reqwest", + "serde", + "serde_json", + "svc", + "thiserror 2.0.12", + "url", + "uuid", +] + +[[package]] +name = "bitflags" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36" + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f61dac84819c6588b558454b194026eb1f09c293b9036ae9b159e74e73ab6cf9" + +[[package]] +name = "camino" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b96ec4966b5813e2c0507c1f86115c8c5abaadc3980879c3424042a02fd1ad3" +dependencies = [ + "serde", +] + +[[package]] +name = "cc" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c3d1b2e905a3a7b00a6141adb0e4c0bb941d11caf55349d863942a1cc44e3c9" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "num-traits", + "serde", + "windows-targets 0.52.6", +] + +[[package]] +name = "clap" +version = "4.5.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acebd8ad879283633b343856142139f2da2317c96b05b4dd6181c61e2480184" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap-stdin" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1101d998d15574d862ee282bcb93e0cf2d192c2fb12338dec35daa91425769a9" +dependencies = [ + "thiserror 2.0.12", +] + +[[package]] +name = "clap_builder" +version = "4.5.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ba32cbda51c7e1dfd49acc1457ba1a7dec5b64fe360e828acb13ca8dc9c2f9" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_derive" +version = "4.5.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4ced95c6f4a675af3da73304b9ac4ed991640c36374e4b46795c49e17cf1ed" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.98", +] + +[[package]] +name = "clap_lex" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" + +[[package]] +name = "colorchoice" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" + +[[package]] +name = "config" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf9dc8d4ef88e27a8cb23e85cb116403dedd57f7971964dc4b18ccead548901" +dependencies = [ + "pathdiff", + "serde", + "winnow", + "yaml-rust2", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "575f75dfd25738df5b91b8e43e14d44bda14637a58fae779fd2b064f8bf3e010" + +[[package]] +name = "der" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0" +dependencies = [ + "const-oid", + "der_derive", + "flagset", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + +[[package]] +name = "diffs" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff116c9781d74b71b9b8958281309dd2faaeabad2f0a3df27e50bd79ce5dc805" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "env_logger" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580" +dependencies = [ + "humantime", + "is-terminal", + "log", + "regex", + "termcolor", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "ff" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ded41244b729663b1e574f1b4fb731469f69f79c17667b5d776b16cda0479449" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "flagset" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3ea1ec5f8307826a5b71094dd91fc04d4ae75d5709b20ad351c7fb4815c86ec" + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[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 = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.13.3+wasi-0.2.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5017294ff4bb30944501348f6f8e42e6ad28f42c8bbef7a74029aff064a4e3c2" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f16ca2af56261c99fba8bac40a10251ce8188205a4c448fbb745a2e4daa76fea" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" +dependencies = [ + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2d708df4e7140240a16cd6ab0ab65c972d7433ab77819ea693fde9c43811e2a" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hue" +version = "0.1.0" +dependencies = [ + "bitflags", + "byteorder", + "chrono", + "hex", + "iana-time-zone", + "mac_address", + "maplit", + "packed_struct", + "serde", + "serde_json", + "siphasher", + "thiserror 2.0.12", + "uuid", +] + +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + +[[package]] +name = "hyper" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "pin-project-lite", + "socket2 0.5.8", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "if-addrs" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a78a89907582615b19f6f0da1af18abf6ff08be259395669b834b057a7ee92d8" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "indexmap" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652" +dependencies = [ + "equivalent", + "hashbrown 0.15.2", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "is-terminal" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e19b23d53f35ce9f56aebc7d1bb4e6ac1e9c0db7ac85c8d1760c04379edced37" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json_diff_ng" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "941b0c4ee9d01c0438aa8eb8fc24efab4b119c218cf1acb98e76619868eeca9b" +dependencies = [ + "diffs", + "regex", + "serde_json", + "thiserror 1.0.69", + "vg_errortools", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.172" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" + +[[package]] +name = "libm" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa" + +[[package]] +name = "libyml" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980" +dependencies = [ + "anyhow", + "version_check", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "litemap" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" + +[[package]] +name = "lock_api" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e" + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix 0.29.0", + "serde", + "winapi", +] + +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "mdns-sd" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ff8cdcd0a1427cad221841adb78f233361940f4a1e9f5228d74f3dbce79f433" +dependencies = [ + "fastrand", + "flume", + "if-addrs", + "log", + "mio", + "socket2 0.5.8", +] + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b1c9bd4fe1f0f8b387f6eb9eb3b4a1aa26185e5750efb9140301703f62cd1b" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +dependencies = [ + "libc", + "log", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.52.0", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nix" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537bc3c4a347b87fd52ac6c03a02ab1302962cfd93373c5d7a112cdc337854cc" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +dependencies = [ + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e" + +[[package]] +name = "openssl" +version = "0.10.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "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.98", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8288979acd84749c744a9014b4382d42b8f7b2592847b5afb2ed29e5d16ede07" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "packed_struct" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36b29691432cc9eff8b282278473b63df73bea49bc3ec5e67f31a3ae9c3ec190" +dependencies = [ + "bitvec", + "packed_struct_codegen", + "serde", +] + +[[package]] +name = "packed_struct_codegen" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cd6706dfe50d53e0f6aa09e12c034c44faacd23e966ae5a209e8bdb8f179f98" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pin-project" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfe2e71e1471fe07709406bf725f710b02927c9c54b2b5b2ec0e8087d97c327d" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e859e6e5bd50440ab63c47e3ebabc90f26251f7c73c3d3e837b74a1cc3fa67" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" + +[[package]] +name = "ppv-lite86" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +dependencies = [ + "zerocopy 0.7.35", +] + +[[package]] +name = "pretty_env_logger" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "865724d4dbe39d9f3dd3b52b88d859d66bcb2d6a0acfd5ea68a65fb66d4bdc1c" +dependencies = [ + "env_logger", + "log", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro2" +version = "1.0.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.37.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165859e9e55f79d67b96c5d96f4e88b6f2695a1972849c15a6a3f5c59fc2c003" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quote" +version = "1.0.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.1", + "zerocopy 0.8.18", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.1", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.15", +] + +[[package]] +name = "rand_core" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88e0da7a2c97baa202165137c158d0a2e824ac465d13d81046727b34cb247d3" +dependencies = [ + "getrandom 0.3.1", + "zerocopy 0.8.18", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "reqwest" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d19c46a6fdd48bc4dab94b6103fccc55d34c67cc0ad04653aad4ea2a07cd7bbb" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-tls", + "hyper-util", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower 0.5.2", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-registry", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "rsa" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c75d7c5c6b673e58bf54d8544a9f432e3a925b0e80f7cd3602ab5c50c55519" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "917ce264624a4b4db1c364dcc35bfca9ded014d0a958cd47ad3e960e988ea51c" + +[[package]] +name = "rustversion" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4" + +[[package]] +name = "ryu" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea1a2d0a644769cc99faa24c3ad26b379b786fe7c36fd3c546254801650e6dd" + +[[package]] +name = "schannel" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + +[[package]] +name = "serde_json" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af99884400da37c88f5e9146b7f1fd0fbcae8f6eec4e9da38b67d05486f814a6" +dependencies = [ + "itoa", + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yml" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" +dependencies = [ + "indexmap", + "itoa", + "libyml", + "memchr", + "ryu", + "serde", + "version_check", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +dependencies = [ + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd" + +[[package]] +name = "socket2" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "socket2" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "svc" +version = "0.1.0" +dependencies = [ + "async-trait", + "futures", + "log", + "pretty_env_logger", + "serde", + "thiserror 2.0.12", + "tokio", + "uuid", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36147f1a48ae0ec2b5b3bc5b537d267457555a10dc06f3dbc8cb11ba3006d3b1" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tempfile" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a40f762a77d2afa88c2d919489e390a12bdd261ed568e60cfa7e48d4e20f0d33" +dependencies = [ + "cfg-if", + "fastrand", + "getrandom 0.3.1", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl 2.0.12", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + +[[package]] +name = "tokio" +version = "1.44.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6b88822cbe49de4185e3a4cbf8321dd487cf5fe0c5c65695fef6346371e9c48" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.5.8", + "tokio-macros", + "windows-sys 0.52.0", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + +[[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-openssl" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59df6849caa43bb7567f9a36f863c447d95a11d5903c9cc334ba32576a27eadd" +dependencies = [ + "openssl", + "openssl-sys", + "tokio", +] + +[[package]] +name = "tokio-ssdp" +version = "0.2.0" +source = "git+https://github.com/chrivers/tokio-ssdp.git?rev=00fc29c3#00fc29c348165e183081c7abcb7e927dcead6f81" +dependencies = [ + "httparse", + "httpdate", + "log", + "rand 0.8.5", + "socket2 0.4.10", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4bf6fecd69fcdede0ec680aaf474cdab988f9de6bc73d3758f0160e3b7025a" +dependencies = [ + "futures-util", + "log", + "native-tls", + "tokio", + "tokio-native-tls", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7fcaa8d55a2bdd6b83ace262b016eca0d79ee02818c5c1bcdf0305114081078" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "403fa3b783d4b626a8ad51d766ab03cb6d2dbfc46b1c5d4448395e6628dc9697" +dependencies = [ + "bitflags", + "bytes", + "http", + "http-body", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + +[[package]] +name = "tracing-core" +version = "0.1.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413083a99c579593656008130e29255e54dcaae495be556cc26888f211648c24" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand 0.8.5", + "sha1", + "thiserror 2.0.12", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + +[[package]] +name = "tzfile" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f59c22c42a2537e4c7ad21a4007273bbc5bebed7f36bc93730a5780e22a4592e" +dependencies = [ + "byteorder", + "chrono", +] + +[[package]] +name = "udp-stream" +version = "0.0.12" +source = "git+https://github.com/chrivers/udp-stream.git?rev=da6c76bb#da6c76bbc82573ca5208ed675d33121a08584efd" +dependencies = [ + "bytes", + "log", + "tokio", +] + +[[package]] +name = "unicode-ident" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a210d160f08b701c8721ba1c726c11662f877ea6b7094007e1ca9a1041945034" + +[[package]] +name = "url" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" +dependencies = [ + "getrandom 0.3.1", + "serde", + "sha1_smol", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vg_errortools" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f0d7932688493f8aac7d65e824820b6506fe68ae7450bf44ac7299b1841a4a6" +dependencies = [ + "thiserror 1.0.69", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasi" +version = "0.13.3+wasi-0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn 2.0.98", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-link" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" + +[[package]] +name = "windows-registry" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3" +dependencies = [ + "windows-result", + "windows-strings", + "windows-targets 0.53.0", +] + +[[package]] +name = "windows-result" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" +dependencies = [ + "windows-link", +] + +[[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.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e4c7e8ceaaf9cb7d7507c974735728ab453b67ef8f18febdd7c11fe59dca8b" +dependencies = [ + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] +name = "winnow" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59690dea168f2198d1a3b0cac23b8063efcd11012f10ae4698f284808c8ef603" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" +dependencies = [ + "bitflags", +] + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid", + "der", + "sha1", + "signature", + "spki", + "tls_codec", +] + +[[package]] +name = "yaml-rust2" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a1a1c0bc9823338a3bdf8c61f994f23ac004c6fa32c08cd152984499b445e8d" +dependencies = [ + "arraydeque", + "encoding_rs", + "hashlink", +] + +[[package]] +name = "yoke" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", + "synstructure", +] + +[[package]] +name = "z2m" +version = "0.1.0" +dependencies = [ + "hue", + "serde", + "serde_json", + "thiserror 2.0.12", +] + +[[package]] +name = "zcl" +version = "0.1.0" +dependencies = [ + "byteorder", + "hex", + "hue", + "packed_struct", + "thiserror 2.0.12", +] + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "byteorder", + "zerocopy-derive 0.7.35", +] + +[[package]] +name = "zerocopy" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79386d31a42a4996e3336b0919ddb90f81112af416270cff95b5f5af22b839c2" +dependencies = [ + "zerocopy-derive 0.8.18", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76331675d372f91bf8d17e13afbd5fe639200b73d01f0fc748bb059f9cca2db7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + +[[package]] +name = "zerofrom" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cff3ee08c995dee1859d998dea82f7374f2826091dd9cd47def953cae446cd2e" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + +[[package]] +name = "zerovec" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ce701bf --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,144 @@ +[package] +name = "bifrost" +version = "0.1.0" + +edition.workspace = true +authors.workspace = true +rust-version.workspace = true +description.workspace = true +readme.workspace = true +repository.workspace = true +license.workspace = true +categories.workspace = true +keywords.workspace = true + +[workspace.package] +edition = "2024" +authors = ["Christian Iversen "] +rust-version = "1.85" +description = "A Philips Hue bridge emulator backed by zigbee2mqtt" +readme = "README.md" +repository = "https://github.com/chrivers/bifrost" +license = "GPL-3.0-only" +categories = ["api-bindings", "simulation", "network-programming"] +keywords = [ + "home-automation", + "hue-lights", + "hue-bridge", + "home-assistant", + "hue", + "zigbee", + "hue-api", + "zigbee2mqtt", + "phillips-hue", +] + +[workspace] +members = [ + "crates/bifrost-api", + "crates/hue", + "crates/svc", + "crates/z2m", + "crates/zcl", +] + +[workspace.lints.rust] +# NOTE: to use llvm-cov, comment out the "unstable_features" restriction: +unstable_features = "forbid" +unused_lifetimes = "warn" +unused_qualifications = "warn" + +# Needed for llvm-cov +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage,coverage_nightly)'] } + +[workspace.lints.clippy] +all = { level = "warn", priority = -1 } +correctness = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } +cargo = { level = "warn", priority = -1 } +nursery = { level = "warn", priority = -1 } +perf = { level = "warn", priority = -1 } +style = { level = "warn", priority = -1 } + +multiple_crate_versions = "allow" +missing_errors_doc = "allow" +missing_panics_doc = "allow" + +[lints] +workspace = true + +[features] +default = [ + "server-banner", +] + +server-banner = ["dep:termcolor"] + +[profile.dev] +debug = "limited" +split-debuginfo = "unpacked" + +[dependencies] +axum = { version = "0.8.1", features = ["json", "tokio", "macros", "multipart", "ws", "tracing"], default-features = false } +axum-core = "0.5.0" +axum-server = { version = "0.7.1", features = ["tls-openssl"], default-features = false } +bytes = "1.10.0" +chrono = { version = "0.4.39", features = ["clock", "serde"], default-features = false } +clap = { version = "4.5.29", features = ["std", "color", "derive", "help", "usage"], default-features = false } +config = { version = "0.15.8", default-features = false, features = ["yaml"] } +futures = "0.3.31" +hyper = "1.6.0" +iana-time-zone = "0.1.61" +log = "0.4.25" +mac_address = { version = "1.1.8", features = ["serde"] } +mdns-sd = "0.13.2" +mime = "0.3.17" +rand = "0.9.0" +serde = { version = "1.0.217", features = ["derive"], default-features = false } +serde_json = "1.0.138" +serde_yml = "0" +thiserror = "2.0.11" +tokio = { version = "1.43.1", features = ["io-util", "process", "rt-multi-thread", "signal"], default-features = false } +tokio-stream = { version = "0.1.17", features = ["sync"], default-features = false } +tokio-tungstenite = { version = "0.26.1", features = ["native-tls"] } +tower = "0.5.2" +tower-http = { version = "0.6.2", features = ["cors", "normalize-path", "trace"], default-features = false } +tracing = "0.1.41" +uuid = { version = "1.13.1", features = ["serde", "v4", "v5"] } +pretty_env_logger = "0.5.0" +camino = { version = "1.1.9", features = ["serde1"] } +x509-cert = { version = "0.2.5", features = ["builder", "hazmat", "pem"], default-features = false } +rsa = "0.9.7" +sha2 = { version = "0.10.8", features = ["oid"] } +p256 = "0.13.2" +ecdsa = { version = "0.16.9", features = ["der"] } +der = { version = "0.7.9", features = ["oid"] } +sha1 = "0.10.6" +rustls-pemfile = "2.2.0" +termcolor = { version = "1.4.1", optional = true } +itertools = "0.14.0" +reqwest = { version = "0.12.12", default-features = false, features = ["json", "native-tls"] } +url = { version = "2.5.4", features = ["serde"] } +hex = "0.4.3" +async-trait = "0.1.86" +hue = { version = "0.1.0", path = "crates/hue" } +zcl = { path = "crates/zcl" } +openssl = "0.10.72" +tokio-util = { version = "0.7.13", features = ["net"] } +tokio-openssl = "0.6.5" +maplit = "1.0.2" +svc = { version = "0.1.0", path = "crates/svc" } +z2m = { version = "0.1.0", path = "crates/z2m" } +quick-xml = { version = "0.37.2", features = ["serialize"] } +tokio-ssdp = { git = "https://github.com/chrivers/tokio-ssdp.git", rev = "00fc29c3" } +udp-stream = { git = "https://github.com/chrivers/udp-stream.git", rev = "da6c76bb" } +native-tls = "0.2.13" +tokio-native-tls = "0.3.1" +tzfile = "0.1.3" +bifrost-api = { version = "0.1.0", path = "crates/bifrost-api", features = ["mac"] } +nix = { version = "0.30.0", default-features = false, features = ["socket"] } + +[dev-dependencies] +clap-stdin = "0.6.0" +json_diff_ng = { version = "0.6.0", default-features = false } +packed_struct = "0.10.1" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..37cd575 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +# Building Stage +ARG RUST_VERSION=1.85 +FROM rust:${RUST_VERSION}-slim-bookworm AS build +WORKDIR /app +COPY LICENSE LICENSE + +RUN --mount=type=bind,source=doc,target=doc \ + --mount=type=bind,source=src,target=src \ + --mount=type=bind,source=crates,target=crates \ + --mount=type=bind,source=Cargo.toml,target=Cargo.toml \ + --mount=type=bind,source=Cargo.lock,target=Cargo.lock \ + < + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cea9eea --- /dev/null +++ b/README.md @@ -0,0 +1,155 @@ +![](doc/logo-title-640x160.png) + +# Bifrost Bridge + +Bifrost enables you to emulate a Philips Hue Bridge to control lights, groups +and scenes from [Zigbee2Mqtt](https://www.zigbee2mqtt.io/). + +If you are already familiar with [DiyHue](https://github.com/diyhue/diyHue), you +might like to read the [comparison with DiyHue](doc/comparison-with-diyhue.md). + +Questions, feedback, comments? Join us on discord + +[![Join Valhalla on Discord](https://discordapp.com/api/guilds/1276604041727578144/widget.png?style=banner2)](https://discord.gg/YvBKjHBJpA) + +## Installation guide + +There are currently three ways you can install Bifrost: + +1. [Install manually](#manual) from source (recommended) +2. [Install it via Docker](#docker) for container-based deployment. +3. Install as Home Assistant Add-on. Please see the + [bifrost-hassio](https://github.com/chrivers/bifrost-hassio) project for + more information. + +### Manual + +To install Bifrost from source, you will need the following: + +1. The rust language toolchain (https://rustup.rs/) +2. At least one zigbee2mqtt server to connect to +3. The MAC address of the network interface you want to run the server on +4. `build-essential` package for compiling the source code (on Debian/Ubuntu systems) + +First, install a few necessary build dependencies: + +```sh +sudo apt install build-essential pkg-config libssl3 libssl-dev +``` + +When you have these things available, install bifrost: + +```sh +cargo install --git https://github.com/chrivers/bifrost.git +``` + +After Cargo has finished downloading, compiling, and installing Bifrost, you +should have the "bifrost" command available to you. + +The last step is to create a configuration for bifrost, `config.yaml`. + +Here's a minimal example: + +```yaml +bridge: + name: Bifrost + mac: 00:11:22:33:44:55 + ipaddress: 10.12.0.20 + netmask: 255.255.255.0 + gateway: 10.12.0.1 + timezone: Europe/Copenhagen + +z2m: + server1: + url: ws://10.0.0.100:8080 +``` + +Please adjust this as needed. Particularly, make **sure** the "mac:" field +matches a mac address on the network interface you want to serve requests from. + +Make sure to read the [configuration reference](doc/config-reference.md) to +learn how to adjust the configuration file. + +This mac address if used to generate a self-signed certificate, so the Hue App +will recognize this as a "real" Hue Bridge. If the mac address is incorrect, +this will not work. [How to find your mac address](doc/how-to-find-mac-linux.md). + +Now you can start Bifrost. Simple start the "bifrost" command from the same +directory where you put the `config.yaml`: + +```sh +bifrost +``` + +At this point, the server should start: (log timestamps omitted for clarity) + +``` + =================================================================== + ███████████ ███ ██████ █████ + ░░███░░░░░███ ░░░ ███░░███ ░░███ + ░███ ░███ ████ ░███ ░░░ ████████ ██████ █████ ███████ + ░██████████ ░░███ ███████ ░░███░░███ ███░░███ ███░░ ░░░███░ + ░███░░░░░███ ░███ ░░░███░ ░███ ░░░ ░███ ░███░░█████ ░███ + ░███ ░███ ░███ ░███ ░███ ░███ ░███ ░░░░███ ░███ ███ + ███████████ █████ █████ █████ ░░██████ ██████ ░░█████ + ░░░░░░░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░░ ░░░░░░ ░░░░░ + =================================================================== + + DEBUG bifrost > Configuration loaded successfully + DEBUG bifrost::server::certificate > Found existing certificate for bridge id [001122fffe334455] + DEBUG bifrost::state > Existing state file found, loading.. + INFO bifrost::mdns > Registered service bifrost-001122334455._hue._tcp.local. + INFO bifrost > Serving mac [00:11:22:33:44:55] + DEBUG bifrost::state > Loading certificate from [cert.pem] + INFO bifrost::server > http listening on 10.12.0.20:80 + INFO bifrost::server > https listening on 10.12.0.20:443 + INFO bifrost::z2m > [server1] Connecting to ws://10.0.0.100:8080 + DEBUG tungstenite::handshake::client > Client handshake done. + DEBUG tungstenite::handshake::client > Client handshake done. + DEBUG bifrost::z2m > [server1] Ignoring unsupported device Coordinator + INFO bifrost::z2m > [server1] Adding light IeeeAddress(000000fffe111111): [office_1] (TRADFRI bulb GU10 CWS 345lm) + INFO bifrost::z2m > [server1] Adding light IeeeAddress(222222fffe333333): [office_2] (TRADFRI bulb GU10 CWS 345lm) + INFO bifrost::z2m > [server1] Adding light IeeeAddress(444444fffe555555): [office_3] (TRADFRI bulb GU10 CWS 345lm) +... +``` + +The log output shows Bifrost talking with zigbee2mqtt, and finding some lights to control (office\_{1,2,3}). + +At this point, you're running a Bifrost bridge. + +The Philips Hue app should be able to find it on your network! + +### Docker + +#### Docker Installation + +To install Bifrost with Docker, you will need the following: + +1. At least one zigbee2mqtt server to connect to +2. The MAC address of the network interface you want to run the server on +3. A running [Docker](https://docs.docker.com/engine/install/) instance + with [Docker-Compose](https://docs.docker.com/compose/install/) installed +4. Have `git` installed to clone this repository + +Please choose one of the following installation methods: + +- [Install using Docker Compose](doc/docker-compose-install.md) (recommended for most users) +- [Install using Docker Image](doc/docker-image-install.md) (for direct image pulls) + +# Configuration + +See [configuration reference](doc/config-reference.md). + +# Problems? Questions? Feedback? + +Please note: Bifrost is a very young project. Some things are incomplete, and/or +broken when they shouldn't be. + +Consider joining us on discord: + +[![Join Valhalla on Discord](https://discordapp.com/api/guilds/1276604041727578144/widget.png?style=banner2)](https://discord.gg/YvBKjHBJpA) + +If you have any problems, questions or suggestions, feel free to [create an +issue](https://github.com/chrivers/bifrost/issues) on this project. + +Also, pull requests are always welcome! diff --git a/crates/.gitignore b/crates/.gitignore new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/crates/.gitignore @@ -0,0 +1 @@ + diff --git a/crates/README.md b/crates/README.md new file mode 100644 index 0000000..d36602e --- /dev/null +++ b/crates/README.md @@ -0,0 +1,59 @@ +# Bifrost crates + +``` + ┌─────────────────────┐ ┌─────────────────────┐ + │ Bifrost │ │ bifrost-frontend │ + └──┬────┬───────┬─┬─┬─┘ └┬────────────┬───────┘ + │ │ │ │ └────┼────────────┼─────┐ + │ │ │ └──────┼──────┐ │ │ + │ ▼ ▼ │ ▼ ▼ │ + │ ┌─────┐ ┌─────┐ │ ┌──────────────┐ │ + │ │ z2m │ │ zcl │ │ │ bifrost-api │ │ + │ └──┬──┘ └──┬──┘ │ └──┬───────┬───┘ │ + │ │ │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ ▼ ▼ + ┌─────────────────────────────────┐ ┌─────────┐ + │ hue │ │ svc │ + └─────────────────────────────────┘ └─────────┘ +``` + + +## `hue`: Philips Hue data models + +Low-level data models for Philips Hue API requests, entertainment mode +streams, and Hue-specific Zigbee encodings. + +## `zcl`: Zigbee Cluster Library + +Serializing and deserializing support for ZCL (Zigbee Cluster Library) +frames. Rudimentary support for standard frames, and cutting-edge support for +Hue-specific frames. + +## `z2m`: Zigbee2MQTT interface + +Rust code for interfacing with Zigbee2MQTT. Serializing and deserializing +support for z2m messages. + +## `svc`: Service management + +Crate to manage, control and communicate with running "services". + +These services (rust functions) are managed through service manager; +a minimal systemd-inspired service "daemon". + +Since all "services" controlled by `svc` are rust functions, these are +*not* system services in the classical sense. + +## `bifrost-api`: Bifrost API + +Data structures and types that define the Bifrost-specific API supported by the +Bifrost server. This is used by `bifrost` and `bifrost-frontend` to have a +well-defined interface between them, but can be used by any program that wants +to communicate with a Bifrost server. + +## `bifrost-frontend`: Dioxus web frontend for Bifrost + +The Bifrost web frontend, made with Dioxus. + +Compiles to a static site (html/css/js/wasm) that communicates with a Bifrost +server. diff --git a/crates/bifrost-api/Cargo.toml b/crates/bifrost-api/Cargo.toml new file mode 100644 index 0000000..beb8ab1 --- /dev/null +++ b/crates/bifrost-api/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "bifrost-api" +version = "0.1.0" +edition.workspace = true +authors.workspace = true +rust-version.workspace = true +description.workspace = true +readme.workspace = true +repository.workspace = true +license.workspace = true +categories.workspace = true +keywords.workspace = true + +[lints] +workspace = true + +[dependencies] +camino = { version = "1.1.9", features = ["serde", "serde1"] } +reqwest = { version = "0.12.15", default-features = false, features = ["json"] } +serde = { version = "1.0.219", features = ["derive"] } +thiserror = "2.0.12" +url = { version = "2.5.4", features = ["serde"] } +uuid = { version = "1.16.0", features = ["serde"] } +serde_json = "1.0.140" + +hue = { version = "0.1.0", path = "../hue", default-features = false, features = ["event"] } +svc = { version = "0.1.0", path = "../svc", default-features = false } + +mac_address = { version = "1.1.8", optional = true } + +[features] +default = [] + +mac = ["dep:mac_address"] diff --git a/crates/bifrost-api/src/backend.rs b/crates/bifrost-api/src/backend.rs new file mode 100644 index 0000000..e68c71b --- /dev/null +++ b/crates/bifrost-api/src/backend.rs @@ -0,0 +1,39 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use hue::api::{ + GroupedLightUpdate, LightUpdate, ResourceLink, RoomUpdate, Scene, SceneUpdate, + ZigbeeDeviceDiscoveryUpdate, +}; +use hue::stream::HueStreamLightsV2; + +use crate::Client; +use crate::config::Z2mServer; +use crate::error::BifrostResult; + +#[allow(clippy::large_enum_variant)] +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum BackendRequest { + LightUpdate(ResourceLink, LightUpdate), + + SceneCreate(ResourceLink, u32, Scene), + SceneUpdate(ResourceLink, SceneUpdate), + + GroupedLightUpdate(ResourceLink, GroupedLightUpdate), + + RoomUpdate(ResourceLink, RoomUpdate), + + Delete(ResourceLink), + + EntertainmentStart(Uuid), + EntertainmentFrame(HueStreamLightsV2), + EntertainmentStop(), + + ZigbeeDeviceDiscovery(ResourceLink, ZigbeeDeviceDiscoveryUpdate), +} + +impl Client { + pub async fn post_backend(&self, name: &str, backend: Z2mServer) -> BifrostResult<()> { + self.post(&format!("backend/z2m/{name}"), backend).await + } +} diff --git a/crates/bifrost-api/src/client.rs b/crates/bifrost-api/src/client.rs new file mode 100644 index 0000000..bc7cb51 --- /dev/null +++ b/crates/bifrost-api/src/client.rs @@ -0,0 +1,62 @@ +use reqwest::{Method, Url}; +use serde::Serialize; +use serde::de::DeserializeOwned; + +use crate::error::BifrostResult; + +#[derive(Clone)] +pub struct Client { + client: reqwest::Client, + url: Url, +} + +impl Client { + #[must_use] + pub const fn new(client: reqwest::Client, url: Url) -> Self { + Self { client, url } + } + + #[must_use] + pub fn from_url(url: Url) -> Self { + Self::new(reqwest::Client::new(), url) + } + + pub async fn request( + &self, + scope: &str, + method: Method, + data: Option, + ) -> BifrostResult { + let url = self.url.join(scope)?; + + let mut req = self.client.request(method, url); + + if let Some(data) = data { + req = req.json(&data); + } + + let response = req.send().await?.error_for_status()?.json().await?; + + Ok(response) + } + + pub async fn get(&self, scope: &str) -> BifrostResult { + self.request(scope, Method::GET, None::<()>).await + } + + pub async fn post( + &self, + scope: &str, + data: I, + ) -> BifrostResult { + self.request(scope, Method::POST, Some(data)).await + } + + pub async fn put( + &self, + scope: &str, + data: I, + ) -> BifrostResult { + self.request(scope, Method::PUT, Some(data)).await + } +} diff --git a/crates/bifrost-api/src/config.rs b/crates/bifrost-api/src/config.rs new file mode 100644 index 0000000..f14197a --- /dev/null +++ b/crates/bifrost-api/src/config.rs @@ -0,0 +1,120 @@ +use std::net::Ipv4Addr; +use std::{collections::BTreeMap, num::NonZeroU32}; + +use camino::Utf8PathBuf; +use hue::api::RoomArchetype; +use serde::{Deserialize, Serialize}; +use url::Url; + +use crate::{Client, error::BifrostResult}; + +#[cfg(feature = "mac")] +use mac_address::MacAddress; +#[cfg(not(feature = "mac"))] +type MacAddress = String; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct BridgeConfig { + pub name: String, + pub mac: MacAddress, + pub ipaddress: Ipv4Addr, + pub http_port: u16, + pub https_port: u16, + pub entm_port: u16, + pub netmask: Ipv4Addr, + pub gateway: Ipv4Addr, + pub timezone: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)] +pub struct BifrostConfig { + pub state_file: Utf8PathBuf, + pub cert_file: Utf8PathBuf, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)] +pub struct Z2mConfig { + #[serde(flatten)] + pub servers: BTreeMap, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)] +pub struct Z2mServer { + pub url: Url, + pub group_prefix: Option, + pub disable_tls_verify: Option, + pub streaming_fps: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Default, Eq, PartialEq)] +pub struct RoomConfig { + pub name: Option, + pub icon: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct AppConfig { + pub bridge: BridgeConfig, + pub z2m: Z2mConfig, + pub bifrost: BifrostConfig, + #[serde(default)] + pub rooms: BTreeMap, +} + +impl Z2mServer { + #[must_use] + pub fn get_url(&self) -> Url { + let mut url = self.url.clone(); + // z2m version 1.x allows both / and /api as endpoints for the + // websocket, but version 2.x only allows /api. By adding /api (if + // missing), we ensure compatibility with both versions. + if !url.path().ends_with("/api") { + if let Ok(mut path) = url.path_segments_mut() { + path.push("api"); + } + } + + // z2m version 2.x requires an auth token on the websocket. If one is + // not specified in the z2m configuration, the literal string + // `your-secret-token` is used! + // + // To be compatible, we mirror this behavior here. If "token" is set + // manually by the user, we do nothing. + if !url.query_pairs().any(|(key, _)| key == "token") { + url.query_pairs_mut() + .append_pair("token", "your-secret-token"); + } + + url + } + + #[must_use] + #[allow(clippy::option_if_let_else)] + fn sanitize_url(url: &str) -> String { + match url.find("token=") { + Some(offset) => { + let token = &url[offset + "token=".len()..]; + if token == "your-secret-token" { + // this is the standard "blank" token, it's safe to show + url.to_string() + } else { + // this is an actual secret token, blank it out with a + // standard-length placeholder. + format!("{}token={}", &url[..offset], "<>") + } + } + None => url.to_string(), + } + } + + #[must_use] + pub fn get_sanitized_url(&self) -> String { + Self::sanitize_url(self.get_url().as_str()) + } +} + +impl Client { + pub async fn config(&self) -> BifrostResult { + self.get("config").await + } +} diff --git a/crates/bifrost-api/src/error.rs b/crates/bifrost-api/src/error.rs new file mode 100644 index 0000000..ddd84b8 --- /dev/null +++ b/crates/bifrost-api/src/error.rs @@ -0,0 +1,15 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum BifrostError { + #[error(transparent)] + ReqwestError(#[from] reqwest::Error), + + #[error(transparent)] + UrlParseError(#[from] url::ParseError), + + #[error("Server error: {0}")] + ServerError(String), +} + +pub type BifrostResult = Result; diff --git a/crates/bifrost-api/src/lib.rs b/crates/bifrost-api/src/lib.rs new file mode 100644 index 0000000..1eddb77 --- /dev/null +++ b/crates/bifrost-api/src/lib.rs @@ -0,0 +1,13 @@ +pub mod backend; +pub mod config; +pub mod error; +pub mod service; +pub mod websocket; + +mod client; +pub use client::*; + +pub mod export { + pub extern crate hue; + pub extern crate svc; +} diff --git a/crates/bifrost-api/src/service.rs b/crates/bifrost-api/src/service.rs new file mode 100644 index 0000000..76025cc --- /dev/null +++ b/crates/bifrost-api/src/service.rs @@ -0,0 +1,38 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use svc::serviceid::ServiceName; +use svc::traits::ServiceState; + +use crate::Client; +use crate::error::BifrostResult; + +#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)] +pub struct Service { + pub id: Uuid, + pub name: ServiceName, + pub state: ServiceState, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Default, Eq, PartialEq)] +pub struct ServiceList { + pub services: BTreeMap, +} + +impl Client { + pub async fn service_list(&self) -> BifrostResult { + self.get("service").await + } + + pub async fn service_stop(&self, id: Uuid) -> BifrostResult { + self.put(&format!("service/{id}"), ServiceState::Stopped) + .await + } + + pub async fn service_start(&self, id: Uuid) -> BifrostResult { + self.put(&format!("service/{id}"), ServiceState::Running) + .await + } +} diff --git a/crates/bifrost-api/src/websocket.rs b/crates/bifrost-api/src/websocket.rs new file mode 100644 index 0000000..a4d279f --- /dev/null +++ b/crates/bifrost-api/src/websocket.rs @@ -0,0 +1,14 @@ +use hue::event::EventBlock; +use serde::{Deserialize, Serialize}; + +use crate::backend::BackendRequest; +use crate::config::AppConfig; +use crate::service::Service; + +#[derive(Debug, Serialize, Deserialize)] +pub enum Update { + AppConfig(AppConfig), + HueEvent(EventBlock), + BackendRequest(BackendRequest), + ServiceUpdate(Service), +} diff --git a/crates/hue/Cargo.toml b/crates/hue/Cargo.toml new file mode 100644 index 0000000..53e90b7 --- /dev/null +++ b/crates/hue/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "hue" +version = "0.1.0" + +edition.workspace = true +authors.workspace = true +rust-version.workspace = true +description.workspace = true +readme.workspace = true +repository.workspace = true +license.workspace = true +categories.workspace = true +keywords.workspace = true + +[lints] +workspace = true + +[dependencies] +bitflags = "2.8.0" +byteorder = "1.5.0" +chrono = { version = "0.4.39", default-features = false, features = ["clock", "std"] } +hex = "0.4.3" +iana-time-zone = "0.1.61" +packed_struct = "0.10.1" +serde = { version = "1.0.217", features = ["derive"] } +serde_json = "1.0.140" +siphasher = "1.0.1" +thiserror = "2.0.11" +uuid = { version = "1.13.1", features = ["serde", "v5"] } + +mac_address = { version = "1.1.8", features = ["serde"], optional = true } +maplit = "1.0.2" + +[features] +default = ["event", "mac", "rng"] + +rng = ["uuid/v4"] +event = [] +mac = ["dep:mac_address"] + +[dev-dependencies] +hex = "0.4.3" +uuid = { version = "1.13.1", features = ["v4"] } diff --git a/crates/hue/src/api/behavior.rs b/crates/hue/src/api/behavior.rs new file mode 100644 index 0000000..5e6fe61 --- /dev/null +++ b/crates/hue/src/api/behavior.rs @@ -0,0 +1,231 @@ +use std::ops::AddAssign; + +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::Value; +use uuid::{Uuid, uuid}; + +use super::{DollarRef, ResourceLink}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct BehaviorScript { + pub configuration_schema: DollarRef, + pub description: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_number_instances: Option, + pub metadata: BehaviorScriptMetadata, + pub state_schema: DollarRef, + pub supported_features: Vec, + pub trigger_schema: DollarRef, + pub version: String, +} + +impl BehaviorScript { + pub const WAKE_UP_ID: Uuid = uuid!("ff8957e3-2eb9-4699-a0c8-ad2cb3ede704"); + + #[must_use] + pub fn wake_up() -> Self { + Self { + configuration_schema: DollarRef { + dref: Some("basic_wake_up_config.json#".to_string()), + }, + description: + "Get your body in the mood to wake up by fading on the lights in the morning." + .to_string(), + max_number_instances: None, + metadata: BehaviorScriptMetadata { + name: "Basic wake up routine".to_string(), + category: "automation".to_string(), + }, + state_schema: DollarRef { dref: None }, + supported_features: vec!["style_sunrise".to_string(), "intensity".to_string()], + trigger_schema: DollarRef { + dref: Some("trigger.json#".to_string()), + }, + version: "0.0.1".to_string(), + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct BehaviorScriptMetadata { + pub name: String, + pub category: String, +} + +fn deserialize_optional_field<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + Ok(Some(Value::deserialize(deserializer)?)) +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct BehaviorInstance { + #[serde(default)] + pub dependees: Vec, + pub enabled: bool, + pub last_error: Option, + pub metadata: BehaviorInstanceMetadata, + pub script_id: Uuid, + pub status: Option, + #[serde( + default, + deserialize_with = "deserialize_optional_field", + skip_serializing_if = "Option::is_none" + )] + pub state: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub migrated_from: Option, + pub configuration: Value, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub enum BehaviorInstanceConfiguration { + Wakeup(WakeupConfiguration), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct WakeupConfiguration { + pub end_brightness: f64, + pub fade_in_duration: configuration::Duration, + #[serde(skip_serializing_if = "Option::is_none")] + pub turn_lights_off_after: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub style: Option, + pub when: configuration::When, + #[serde(rename = "where")] + pub where_field: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum WakeupStyle { + Sunrise, + Basic, +} + +pub mod configuration { + use std::time::Duration as StdDuration; + + use chrono::Weekday; + use serde::{Deserialize, Serialize}; + + use crate::api::ResourceLink; + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + pub struct Duration { + pub seconds: u32, + } + + impl Duration { + pub fn to_std(&self) -> StdDuration { + StdDuration::from_secs(self.seconds.into()) + } + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + pub struct When { + pub recurrence_days: Option>, + pub time_point: TimePoint, + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + #[serde(tag = "type", rename_all = "snake_case")] + pub enum TimePoint { + Time { time: Time }, + } + + impl TimePoint { + pub const fn time(&self) -> &Time { + match self { + Self::Time { time } => time, + } + } + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + pub struct Time { + pub hour: u32, + pub minute: u32, + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + pub struct Where { + pub group: ResourceLink, + pub items: Option>, + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct BehaviorInstanceDependee { + #[serde(rename = "type")] + pub type_field: Option, + pub target: ResourceLink, + pub level: BehaviorInstanceDependeeLevel, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum BehaviorInstanceDependeeLevel { + Critical, + NonCritical, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct BehaviorInstanceMetadata { + pub name: String, +} + +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub struct BehaviorInstanceUpdate { + pub configuration: Option, + pub enabled: Option, + pub metadata: Option, +} + +impl BehaviorInstanceUpdate { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub fn with_metadata(self, metadata: BehaviorInstanceMetadata) -> Self { + Self { + metadata: Some(metadata), + ..self + } + } + + #[must_use] + pub fn with_enabled(self, enabled: bool) -> Self { + Self { + enabled: Some(enabled), + ..self + } + } + + #[must_use] + pub fn with_configuration(self, configuration: Value) -> Self { + Self { + configuration: Some(configuration), + ..self + } + } +} + +impl AddAssign for BehaviorInstance { + fn add_assign(&mut self, upd: BehaviorInstanceUpdate) { + if let Some(md) = upd.metadata { + self.metadata = md; + } + + if let Some(enabled) = upd.enabled { + self.enabled = enabled; + } + + if let Some(configuration) = upd.configuration { + self.configuration = configuration; + } + } +} diff --git a/crates/hue/src/api/device.rs b/crates/hue/src/api/device.rs new file mode 100644 index 0000000..ffbe44a --- /dev/null +++ b/crates/hue/src/api/device.rs @@ -0,0 +1,216 @@ +use std::collections::BTreeSet; +use std::ops::{AddAssign, Sub}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::HUE_BRIDGE_V2_MODEL_ID; +use crate::api::{Metadata, MetadataUpdate, RType, ResourceLink, Stub}; +use crate::version::SwVersion; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Device { + pub product_data: DeviceProductData, + pub metadata: Metadata, + pub services: BTreeSet, + #[serde(skip_serializing_if = "Option::is_none")] + pub usertest: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub identify: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct DeviceUpdate { + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub services: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub product_data: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub identify: Option, +} + +impl Device { + #[must_use] + pub fn service(&self, rtype: RType) -> Option<&ResourceLink> { + self.services.iter().find(|rl| rl.rtype == rtype) + } + + #[must_use] + pub fn light_service(&self) -> Option<&ResourceLink> { + self.service(RType::Light) + } + + #[must_use] + pub fn entertainment_service(&self) -> Option<&ResourceLink> { + self.service(RType::Entertainment) + } +} + +#[derive(Debug, Serialize, Deserialize, Copy, Clone, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum DeviceIdentify { + Identify, +} + +#[derive(Debug, Serialize, Deserialize, Copy, Clone, PartialEq, Eq)] +pub struct DeviceIdentifyUpdate { + pub action: DeviceIdentify, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)] +pub struct Identify {} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct UserTest { + status: String, + usertest: bool, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct DeviceProductData { + pub model_id: String, + pub manufacturer_name: String, + pub product_name: String, + pub product_archetype: DeviceArchetype, + pub certified: bool, + pub software_version: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub hardware_platform_type: Option, +} + +impl DeviceProductData { + pub const SIGNIFY_MANUFACTURER_NAME: &'static str = "Signify Netherlands B.V."; + + #[must_use] + pub fn hue_bridge_v2(version: &SwVersion) -> Self { + Self { + certified: true, + manufacturer_name: Self::SIGNIFY_MANUFACTURER_NAME.to_string(), + model_id: HUE_BRIDGE_V2_MODEL_ID.to_string(), + product_archetype: DeviceArchetype::BridgeV2, + product_name: "Hue Bridge".to_string(), + software_version: version.get_software_version(), + hardware_platform_type: None, + } + } +} + +impl DeviceUpdate { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub fn with_metadata(self, metadata: Metadata) -> Self { + Self { + metadata: Some(MetadataUpdate { + archetype: Some(metadata.archetype), + name: Some(metadata.name), + function: None, + }), + ..self + } + } +} + +impl AddAssign<&DeviceUpdate> for Device { + fn add_assign(&mut self, upd: &DeviceUpdate) { + if let Some(md) = &upd.metadata { + if let Some(name) = &md.name { + self.metadata.name.clone_from(name); + } + if let Some(archetype) = &md.archetype { + self.metadata.archetype.clone_from(archetype); + } + } + } +} + +#[allow(clippy::if_not_else)] +impl Sub<&Device> for &Device { + type Output = DeviceUpdate; + + fn sub(self, rhs: &Device) -> Self::Output { + let mut upd = Self::Output::default(); + + if self.metadata != rhs.metadata { + upd.metadata = Some(MetadataUpdate { + name: if self.metadata.name != rhs.metadata.name { + Some(rhs.metadata.name.clone()) + } else { + None + }, + archetype: if self.metadata.archetype != rhs.metadata.archetype { + Some(rhs.metadata.archetype.clone()) + } else { + None + }, + function: None, + }); + } + + upd + } +} + +#[derive(Debug, Default, Serialize, Deserialize, Clone, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum DeviceArchetype { + BridgeV2, + #[default] + UnknownArchetype, + ClassicBulb, + SultanBulb, + FloodBulb, + SpotBulb, + CandleBulb, + LusterBulb, + PendantRound, + PendantLong, + CeilingRound, + CeilingSquare, + FloorShade, + FloorLantern, + TableShade, + RecessedCeiling, + RecessedFloor, + SingleSpot, + DoubleSpot, + TableWash, + WallLantern, + WallShade, + FlexibleLamp, + GroundSpot, + WallSpot, + Plug, + HueGo, + HueLightstrip, + HueIris, + HueBloom, + Bollard, + WallWasher, + HuePlay, + VintageBulb, + VintageCandleBulb, + EllipseBulb, + TriangleBulb, + SmallGlobeBulb, + LargeGlobeBulb, + EdisonBulb, + ChristmasTree, + StringLight, + HueCentris, + HueLightstripTv, + HueLightstripPc, + HueTube, + HueSigne, + PendantSpot, + CeilingHorizontal, + CeilingTube, + + #[serde(untagged)] + Other(String), +} diff --git a/crates/hue/src/api/entertainment.rs b/crates/hue/src/api/entertainment.rs new file mode 100644 index 0000000..61105a1 --- /dev/null +++ b/crates/hue/src/api/entertainment.rs @@ -0,0 +1,30 @@ +use serde::{Deserialize, Serialize}; + +use crate::api::ResourceLink; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Entertainment { + pub equalizer: bool, + pub owner: ResourceLink, + pub proxy: bool, + pub renderer: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_streams: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub renderer_reference: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub segments: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct EntertainmentSegments { + pub configurable: bool, + pub max_segments: u32, + pub segments: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct EntertainmentSegment { + pub length: u32, + pub start: u32, +} diff --git a/crates/hue/src/api/entertainment_config.rs b/crates/hue/src/api/entertainment_config.rs new file mode 100644 index 0000000..aa79169 --- /dev/null +++ b/crates/hue/src/api/entertainment_config.rs @@ -0,0 +1,229 @@ +use serde::{Deserialize, Serialize}; + +use crate::api::ResourceLink; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct EntertainmentConfiguration { + pub name: String, + pub configuration_type: EntertainmentConfigurationType, + pub metadata: EntertainmentConfigurationMetadata, + pub status: EntertainmentConfigurationStatus, + pub stream_proxy: EntertainmentConfigurationStreamProxy, + pub locations: EntertainmentConfigurationLocations, + pub light_services: Vec, + pub channels: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub active_streamer: Option, +} + +impl EntertainmentConfiguration { + #[must_use] + pub fn is_streaming(&self) -> bool { + self.active_streamer.is_some() || self.status != EntertainmentConfigurationStatus::Inactive + } + + pub const fn stop_streaming(&mut self) { + self.active_streamer = None; + self.status = EntertainmentConfigurationStatus::Inactive; + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum EntertainmentConfigurationStatus { + Active, + Inactive, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct EntertainmentConfigurationMetadata { + pub name: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct EntertainmentConfigurationStreamProxy { + pub mode: EntertainmentConfigurationStreamProxyMode, + pub node: ResourceLink, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct EntertainmentConfigurationLocations { + pub service_locations: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct EntertainmentConfigurationServiceLocations { + pub equalization_factor: f64, + pub position: Position, + pub positions: Vec, + pub service: ResourceLink, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct EntertainmentConfigurationChannels { + pub channel_id: u32, + pub position: Position, + pub members: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct Position { + pub x: f64, + pub y: f64, + pub z: f64, +} + +impl Position { + #[must_use] + pub const fn new(x: f64, y: f64, z: f64) -> Self { + Self { x, y, z } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct EntertainmentConfigurationStreamMembers { + pub service: ResourceLink, + pub index: u16, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "lowercase")] +pub enum EntertainmentConfigurationAction { + Start, + Stop, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "lowercase")] +pub enum EntertainmentConfigurationType { + Screen, + Monitor, + Music, + #[serde(rename = "3dspace")] + Space3D, + Other, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct EntertainmentConfigurationUpdate { + pub configuration_type: Option, + pub metadata: Option, + pub action: Option, + pub stream_proxy: Option, + pub locations: Option, +} + +impl EntertainmentConfigurationUpdate { + #[must_use] + pub fn new() -> Self { + Self::default() + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(deny_unknown_fields)] +pub struct EntertainmentConfigurationNew { + pub configuration_type: EntertainmentConfigurationType, + pub metadata: EntertainmentConfigurationMetadata, + pub stream_proxy: Option, + pub locations: EntertainmentConfigurationLocationsNew, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum EntertainmentConfigurationStreamProxyMode { + Auto, + Manual, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(tag = "mode", rename_all = "snake_case")] +pub enum EntertainmentConfigurationStreamProxyUpdate { + Auto, + Manual { node: ResourceLink }, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct EntertainmentConfigurationLocationsUpdate { + pub service_locations: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct EntertainmentConfigurationLocationsNew { + pub service_locations: Vec, +} + +impl From + for EntertainmentConfigurationServiceLocationsUpdate +{ + fn from(value: EntertainmentConfigurationServiceLocationsNew) -> Self { + Self { + equalization_factor: Some(1.0), + positions: value.positions, + service: value.service, + } + } +} + +impl From + for EntertainmentConfigurationServiceLocationsUpdate +{ + fn from(value: EntertainmentConfigurationServiceLocations) -> Self { + Self { + equalization_factor: Some(value.equalization_factor), + service: value.service, + positions: value.positions, + } + } +} + +impl From + for EntertainmentConfigurationServiceLocations +{ + fn from(value: EntertainmentConfigurationServiceLocationsUpdate) -> Self { + Self { + equalization_factor: value.equalization_factor.unwrap_or(1.0), + service: value.service, + position: value.positions.first().cloned().unwrap_or_default(), + positions: value.positions, + } + } +} + +impl From + for EntertainmentConfigurationServiceLocations +{ + fn from(value: EntertainmentConfigurationServiceLocationsNew) -> Self { + Self { + equalization_factor: 1.0, + service: value.service, + position: value.positions.first().cloned().unwrap_or_default(), + positions: value.positions, + } + } +} + +impl From for EntertainmentConfigurationLocationsUpdate { + fn from(value: EntertainmentConfigurationLocationsNew) -> Self { + Self { + service_locations: value + .service_locations + .into_iter() + .map(Into::into) + .collect(), + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct EntertainmentConfigurationServiceLocationsUpdate { + pub equalization_factor: Option, + pub positions: Vec, + pub service: ResourceLink, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct EntertainmentConfigurationServiceLocationsNew { + pub positions: Vec, + pub service: ResourceLink, +} diff --git a/crates/hue/src/api/grouped_light.rs b/crates/hue/src/api/grouped_light.rs new file mode 100644 index 0000000..6dee0b5 --- /dev/null +++ b/crates/hue/src/api/grouped_light.rs @@ -0,0 +1,149 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::api::{ColorTemperatureUpdate, ColorUpdate, DimmingUpdate, On, ResourceLink, Stub}; +use crate::legacy_api::ApiLightStateUpdate; +use crate::xy::XY; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct GroupedLight { + pub alert: Value, + pub dimming: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub color_temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub color_temperature_delta: Option, + #[serde(default)] + pub dimming_delta: Stub, + #[serde(default)] + pub dynamics: Stub, + pub on: Option, + pub owner: ResourceLink, + pub signaling: Value, +} + +impl GroupedLight { + #[must_use] + pub const fn new(room: ResourceLink) -> Self { + Self { + alert: Value::Null, + dimming: None, + color: Some(Stub), + color_temperature: Some(Stub), + color_temperature_delta: Some(Stub), + dimming_delta: Stub, + dynamics: Stub, + on: None, + owner: room, + signaling: Value::Null, + } + } + + #[must_use] + pub fn as_brightness_opt(&self) -> Option { + self.dimming.as_ref().map(|br| br.brightness) + } +} + +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub struct GroupedLightDynamicsUpdate { + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, +} + +impl GroupedLightDynamicsUpdate { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub fn with_duration(self, duration: Option>) -> Self { + Self { + duration: duration.map(Into::into), + ..self + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct GroupedLightUpdate { + #[serde(skip_serializing_if = "Option::is_none")] + pub on: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub dimming: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub color_temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub owner: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub dynamics: Option, +} + +impl GroupedLightUpdate { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub fn with_brightness(self, brightness: Option) -> Self { + Self { + dimming: brightness.map(DimmingUpdate::new), + ..self + } + } + + #[must_use] + pub const fn with_on(self, on: Option) -> Self { + Self { on, ..self } + } + + #[must_use] + pub const fn with_color_temperature(self, mirek: Option) -> Self { + Self { + color_temperature: if let Some(ct) = mirek { + Some(ColorTemperatureUpdate::new(ct)) + } else { + None + }, + ..self + } + } + + #[must_use] + pub const fn with_color_xy(self, val: Option) -> Self { + Self { + color: if let Some(xy) = val { + Some(ColorUpdate { xy }) + } else { + None + }, + ..self + } + } + + #[must_use] + pub const fn with_dynamics(self, dynamics: Option) -> Self { + Self { dynamics, ..self } + } +} + +/* conversion from v1 api */ +impl From<&ApiLightStateUpdate> for GroupedLightUpdate { + fn from(upd: &ApiLightStateUpdate) -> Self { + Self::new() + .with_on(upd.on.map(On::new)) + .with_brightness(upd.bri.map(|b| f64::from(b) / 2.54)) + .with_color_xy(upd.xy.map(XY::from)) + .with_color_temperature(upd.ct) + .with_dynamics( + upd.transitiontime + .map(|t| GroupedLightDynamicsUpdate::new().with_duration(Some(t * 100))), + ) + } +} diff --git a/crates/hue/src/api/light.rs b/crates/hue/src/api/light.rs new file mode 100644 index 0000000..fae339f --- /dev/null +++ b/crates/hue/src/api/light.rs @@ -0,0 +1,909 @@ +use std::collections::BTreeSet; +use std::ops::{AddAssign, Sub}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::api::device::DeviceIdentifyUpdate; +use crate::api::{DeviceArchetype, Identify, Metadata, MetadataUpdate, ResourceLink, Stub}; +use crate::hs::HS; +use crate::legacy_api::ApiLightStateUpdate; +use crate::xy::XY; + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct Light { + pub owner: ResourceLink, + pub metadata: LightMetadata, + #[serde(skip_serializing_if = "Option::is_none")] + pub product_data: Option, + + pub alert: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub color_temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub color_temperature_delta: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub dimming: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub dimming_delta: Option, + pub dynamics: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub effects: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub effects_v2: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub service_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub gradient: Option, + #[serde(default)] + pub identify: Identify, + #[serde(skip_serializing_if = "Option::is_none")] + pub timed_effects: Option, + pub mode: LightMode, + pub on: On, + #[serde(skip_serializing_if = "Option::is_none")] + pub powerup: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub signaling: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum LightFunction { + Functional, + Decorative, + Mixed, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)] +pub struct LightMetadata { + pub name: String, + pub archetype: DeviceArchetype, + #[serde(skip_serializing_if = "Option::is_none")] + pub function: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub fixed_mired: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct LightProductData { + #[serde(skip_serializing_if = "Option::is_none")] + pub function: Option, +} + +impl LightMetadata { + #[must_use] + pub fn new(archetype: DeviceArchetype, name: &str) -> Self { + Self { + archetype, + name: name.to_string(), + function: Some(LightFunction::Decorative), + fixed_mired: None, + } + } +} + +impl From for Metadata { + fn from(value: LightMetadata) -> Self { + Self { + name: value.name, + archetype: value.archetype, + } + } +} + +impl Light { + #[must_use] + pub fn new(owner: ResourceLink, metadata: LightMetadata) -> Self { + Self { + alert: Some(LightAlert { + action_values: BTreeSet::from([String::from("breathe")]), + }), + color: None, + color_temperature: None, + color_temperature_delta: Some(Stub), + dimming: None, + dimming_delta: Some(Stub), + dynamics: Some(LightDynamics::default()), + effects: None, + effects_v2: None, + service_id: Some(0), + gradient: None, + identify: Identify {}, + timed_effects: Some(LightTimedEffects { + status_values: Vec::from(LightTimedEffect::ALL), + status: LightTimedEffect::NoEffect, + effect_values: Vec::from(LightTimedEffect::ALL), + }), + mode: LightMode::Normal, + on: On { on: true }, + product_data: Some(LightProductData { + function: Some(LightFunction::Decorative), + }), + metadata, + owner, + powerup: Some(LightPowerup { + preset: LightPowerupPreset::Safety, + configured: true, + on: LightPowerupOn::On { + on: On { on: true }, + }, + dimming: LightPowerupDimming::Dimming { + dimming: DimmingUpdate { brightness: 100.0 }, + }, + color: LightPowerupColor::ColorTemperature { + color_temperature: ColorTemperatureUpdate::new(366), + }, + }), + signaling: Some(LightSignaling { + signal_values: vec![ + LightSignal::NoSignal, + LightSignal::OnOff, + LightSignal::OnOffColor, + LightSignal::Alternating, + ], + status: Value::Null, + }), + } + } + + #[must_use] + pub fn as_dimming_opt(&self) -> Option { + self.dimming.as_ref().map(|dim| DimmingUpdate { + brightness: dim.brightness, + }) + } + + #[must_use] + pub fn as_mirek_opt(&self) -> Option { + self.color_temperature.as_ref().and_then(|ct| ct.mirek) + } + + #[must_use] + pub fn as_color_opt(&self) -> Option { + self.color.as_ref().map(|col| col.xy) + } + + #[must_use] + pub fn as_gradient_opt(&self) -> Option { + self.gradient.as_ref().map(|grad| LightGradientUpdate { + mode: Some(grad.mode), + points: grad.points.clone(), + }) + } + + #[must_use] + pub fn is_streaming(&self) -> bool { + self.mode == LightMode::Streaming + } + + pub const fn stop_streaming(&mut self) { + self.mode = LightMode::Normal; + } +} + +impl AddAssign<&LightUpdate> for Light { + fn add_assign(&mut self, upd: &LightUpdate) { + if let Some(md) = &upd.metadata { + if let Some(name) = &md.name { + self.metadata.name.clone_from(name); + } + if let Some(archetype) = &md.archetype { + self.metadata.archetype = archetype.clone(); + } + } + + if let Some(state) = upd.on { + self.on.on = state.on; + } + + if let Some(dim) = &mut self.dimming { + if let Some(b) = upd.dimming { + dim.brightness = b.brightness; + } + } + + if let Some(ct) = &mut self.color_temperature { + ct.mirek = upd.color_temperature.and_then(|c| c.mirek); + } + + if let Some(col) = upd.color { + if let Some(lcol) = &mut self.color { + lcol.xy = col.xy; + } + if let Some(ct) = &mut self.color_temperature { + ct.mirek = None; + } + } + + if let Some(grad) = &mut self.gradient { + if let Some(grupd) = &upd.gradient { + grad.mode = grupd.mode.unwrap_or(grad.mode); + grad.points.clone_from(&grupd.points); + } + } + } +} + +#[allow(clippy::if_not_else)] +impl Sub<&Light> for &Light { + type Output = LightUpdate; + + fn sub(self, rhs: &Light) -> Self::Output { + let mut upd = Self::Output::default(); + + if self.metadata != rhs.metadata { + upd.metadata = Some(MetadataUpdate { + name: if self.metadata.name != rhs.metadata.name { + Some(rhs.metadata.name.clone()) + } else { + None + }, + archetype: if self.metadata.archetype != rhs.metadata.archetype { + Some(rhs.metadata.archetype.clone()) + } else { + None + }, + function: if self.metadata.function != rhs.metadata.function { + rhs.metadata.function.clone() + } else { + None + }, + }); + } + + if self.on != rhs.on { + upd.on = Some(rhs.on); + } + + if self.dimming != rhs.dimming { + upd.dimming = rhs.dimming.map(Into::into); + } + + if self.as_mirek_opt() != rhs.as_mirek_opt() { + upd = upd.with_color_temperature(rhs.as_mirek_opt()); + } + + if self.as_color_opt() != rhs.as_color_opt() { + upd = upd.with_color_xy(rhs.as_color_opt()); + } + + if self.gradient != rhs.gradient { + upd = upd.with_color_xy(rhs.as_color_opt()); + } + + upd + } +} + +#[derive(Copy, Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum LightMode { + #[default] + Normal, + Streaming, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct LightAlert { + action_values: BTreeSet, +} + +#[derive(Debug, Default, Serialize, Deserialize, Clone, Copy, PartialOrd, Ord, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum LightGradientMode { + #[default] + InterpolatedPalette, + InterpolatedPaletteMirrored, + RandomPixelated, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq)] +pub struct LightGradientPoint { + pub color: ColorUpdate, +} + +impl LightGradientPoint { + #[must_use] + pub const fn xy(xy: XY) -> Self { + Self { + color: ColorUpdate { xy }, + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct LightGradient { + pub mode: LightGradientMode, + pub mode_values: BTreeSet, + pub points_capable: u32, + pub points: Vec, + pub pixel_count: u32, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct LightGradientUpdate { + #[serde(default)] + pub mode: Option, + #[serde(default)] + pub points: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum LightPowerupPreset { + Safety, + Powerfail, + LastOnState, + Custom, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct LightPowerup { + pub preset: LightPowerupPreset, + + pub configured: bool, + #[serde(default, skip_serializing_if = "LightPowerupOn::is_none")] + pub on: LightPowerupOn, + #[serde(default, skip_serializing_if = "LightPowerupDimming::is_none")] + pub dimming: LightPowerupDimming, + #[serde(default, skip_serializing_if = "LightPowerupColor::is_none")] + pub color: LightPowerupColor, +} + +#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(tag = "mode", rename_all = "snake_case")] +pub enum LightPowerupOn { + // Not a real powerup.on.mode option, but used to indicate that + // powerup.on itself is null + #[default] + None, + Previous, + On { + on: On, + }, +} + +impl LightPowerupOn { + #[must_use] + pub const fn is_none(&self) -> bool { + matches!(self, Self::None) + } +} + +#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq)] +#[serde(tag = "mode", rename_all = "snake_case")] +pub enum LightPowerupColor { + // Not a real powerup.color.mode option, but used to indicate that + // powerup.color itself is null + #[default] + None, + Previous, + Color { + color: ColorUpdate, + }, + ColorTemperature { + color_temperature: ColorTemperatureUpdate, + }, +} + +impl LightPowerupColor { + #[must_use] + pub const fn is_none(&self) -> bool { + matches!(self, Self::None) + } +} + +#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq)] +#[serde(tag = "mode", rename_all = "snake_case")] +pub enum LightPowerupDimming { + // Not a real powerup.dimming.mode option, but used to indicate that + // powerup.dimming itself is null + #[default] + None, + Previous, + Dimming { + dimming: DimmingUpdate, + }, +} + +impl LightPowerupDimming { + #[must_use] + pub const fn is_none(&self) -> bool { + matches!(self, Self::None) + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct LightSignaling { + pub signal_values: Vec, + #[serde(default)] + #[serde(skip_serializing_if = "Value::is_null")] + pub status: Value, +} + +#[derive(Copy, Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum LightSignal { + #[default] + NoSignal, + OnOff, + OnOffColor, + Alternating, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum LightDynamicsStatus { + DynamicPalette, + None, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct LightDynamics { + pub status: LightDynamicsStatus, + pub status_values: Vec, + pub speed: f64, + pub speed_valid: bool, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct LightDynamicsUpdate { + #[serde(skip_serializing_if = "Option::is_none")] + pub speed: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, +} + +impl LightDynamicsUpdate { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub fn with_duration(self, duration: Option>) -> Self { + Self { + duration: duration.map(Into::into), + ..self + } + } +} + +impl Default for LightDynamics { + fn default() -> Self { + Self { + status: LightDynamicsStatus::None, + status_values: vec![ + LightDynamicsStatus::None, + LightDynamicsStatus::DynamicPalette, + ], + speed: 0.0, + speed_valid: false, + } + } +} + +#[derive(Debug, Default, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum LightEffect { + #[default] + NoEffect, + Prism, + Opal, + Glisten, + Sparkle, + Fire, + Candle, + Underwater, + Cosmos, + Sunbeam, + Enchant, +} + +impl LightEffect { + pub const ALL: [Self; 11] = [ + Self::NoEffect, + Self::Candle, + Self::Fire, + Self::Prism, + Self::Sparkle, + Self::Opal, + Self::Glisten, + Self::Underwater, + Self::Cosmos, + Self::Sunbeam, + Self::Enchant, + ]; +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct LightEffects { + pub status_values: Vec, + pub status: LightEffect, + pub effect_values: Vec, +} + +impl LightEffects { + #[must_use] + pub fn all() -> Self { + Self { + status_values: Vec::from(LightEffect::ALL), + status: LightEffect::NoEffect, + effect_values: Vec::from(LightEffect::ALL), + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct LightEffectsV2 { + pub action: LightEffectValues, + pub status: LightEffectStatus, +} + +impl LightEffectsV2 { + #[must_use] + pub fn all() -> Self { + Self { + action: LightEffectValues { + effect_values: Vec::from(LightEffect::ALL), + }, + status: LightEffectStatus { + effect: LightEffect::NoEffect, + effect_values: Vec::from(LightEffect::ALL), + parameters: None, + }, + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct LightEffectsUpdate { + #[serde(skip_serializing_if = "Option::is_none")] + pub action: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct LightEffectsV2Update { + #[serde(skip_serializing_if = "Option::is_none")] + pub action: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(deny_unknown_fields)] +pub struct LightEffectActionUpdate { + #[serde(default)] + pub effect: Option, + pub parameters: LightEffectParameters, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(deny_unknown_fields)] +pub struct LightEffectParameters { + #[serde(default)] + pub color: Option, + #[serde(default)] + pub color_temperature: Option, + pub speed: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct LightEffectValues { + pub effect_values: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct LightEffectStatus { + pub effect: LightEffect, + pub effect_values: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parameters: Option, +} + +#[derive(Debug, Default, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum LightTimedEffect { + #[default] + NoEffect, + Sunrise, + Sunset, +} + +impl LightTimedEffect { + pub const ALL: [Self; 3] = [Self::NoEffect, Self::Sunrise, Self::Sunset]; +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct LightTimedEffects { + pub status_values: Vec, + pub status: LightTimedEffect, + pub effect_values: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct LightTimedEffectsUpdate { + #[serde(skip_serializing_if = "Option::is_none")] + pub effect: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct LightUpdate { + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub on: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub dimming: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub color_temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub gradient: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub effects: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub effects_v2: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub service_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub owner: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub powerup: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub dynamics: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub identify: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub timed_effects: Option, +} + +impl LightUpdate { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub fn with_brightness(self, dim: Option>) -> Self { + Self { + dimming: dim.map(Into::into).map(DimmingUpdate::new), + ..self + } + } + + #[must_use] + pub fn with_on(self, on: impl Into>) -> Self { + Self { + on: on.into(), + ..self + } + } + + #[must_use] + pub fn with_color_temperature(self, mirek: impl Into>) -> Self { + Self { + color_temperature: mirek.into().map(ColorTemperatureUpdate::new), + ..self + } + } + + #[must_use] + pub fn with_color_xy(self, xy: impl Into>) -> Self { + Self { + color: self.color.or_else(|| xy.into().map(ColorUpdate::new)), + ..self + } + } + + #[must_use] + pub fn with_color_hs(self, hs: impl Into>) -> Self { + Self { + color: hs.into().map(|hs| XY::from_hs(hs).0).map(ColorUpdate::new), + ..self + } + } + + #[must_use] + pub fn with_identify(self, identify: Option) -> Self { + Self { identify, ..self } + } + + #[must_use] + pub fn with_gradient(self, gradient: Option) -> Self { + Self { gradient, ..self } + } + + #[must_use] + pub fn with_dynamics(self, dynamics: Option) -> Self { + Self { dynamics, ..self } + } +} + +impl From<&ApiLightStateUpdate> for LightUpdate { + fn from(upd: &ApiLightStateUpdate) -> Self { + Self::new() + .with_on(upd.on.map(On::new)) + .with_brightness(upd.bri.map(|b| f64::from(b) / 2.54)) + .with_color_temperature(upd.ct) + .with_color_hs(upd.hs.map(Into::into)) + .with_color_xy(upd.xy.map(Into::into)) + .with_dynamics( + upd.transitiontime + .map(|t| LightDynamicsUpdate::new().with_duration(Some(t * 100))), + ) + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq)] +pub struct DimmingUpdate { + pub brightness: f64, +} + +impl DimmingUpdate { + #[must_use] + pub const fn new(brightness: f64) -> Self { + Self { brightness } + } +} + +impl From for DimmingUpdate { + fn from(value: Dimming) -> Self { + Self { + brightness: value.brightness, + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Delta {} + +#[derive(Copy, Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct On { + pub on: bool, +} + +impl On { + #[must_use] + pub const fn new(on: bool) -> Self { + Self { on } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq)] +pub struct ColorUpdate { + pub xy: XY, +} + +impl ColorUpdate { + #[must_use] + pub const fn new(xy: XY) -> Self { + Self { xy } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)] +pub struct ColorTemperatureUpdate { + pub mirek: Option, +} + +impl ColorTemperatureUpdate { + #[must_use] + pub const fn new(mirek: u16) -> Self { + Self { mirek: Some(mirek) } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct ColorGamut { + pub red: XY, + pub green: XY, + pub blue: XY, +} + +impl ColorGamut { + pub const GAMUT_C: Self = Self { + red: XY { + x: 0.6915, + y: 0.3083, + }, + green: XY { + x: 0.1700, + y: 0.7000, + }, + blue: XY { + x: 0.1532, + y: 0.0475, + }, + }; + + pub const IKEA_ESTIMATE: Self = Self { + red: XY { + x: 0.681_235, + y: 0.318_186, + }, + green: XY { + x: 0.391_898, + y: 0.525_033, + }, + blue: XY { + x: 0.150_241, + y: 0.027_116, + }, + }; +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub enum GamutType { + A, + B, + C, + #[serde(rename = "other")] + Other, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct LightColor { + #[serde(skip_serializing_if = "Option::is_none")] + pub gamut: Option, + pub gamut_type: GamutType, + pub xy: XY, +} + +impl LightColor { + #[must_use] + pub const fn new(xy: XY) -> Self { + Self { + gamut: None, + gamut_type: GamutType::Other, + xy, + } + } +} + +#[derive(Copy, Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct MirekSchema { + pub mirek_minimum: u32, + pub mirek_maximum: u32, +} + +impl MirekSchema { + pub const DEFAULT: Self = Self { + mirek_minimum: 153, + mirek_maximum: 500, + }; +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct ColorTemperature { + pub mirek: Option, + pub mirek_schema: MirekSchema, + pub mirek_valid: bool, +} + +impl From for Option { + fn from(value: ColorTemperature) -> Self { + value.mirek.map(ColorTemperatureUpdate::new) + } +} + +#[derive(Copy, Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct Dimming { + pub brightness: f64, + #[serde(skip_serializing_if = "Option::is_none")] + pub min_dim_level: Option, +} + +impl From for f64 { + fn from(value: Dimming) -> Self { + value.brightness + } +} diff --git a/crates/hue/src/api/mod.rs b/crates/hue/src/api/mod.rs new file mode 100644 index 0000000..faa64d0 --- /dev/null +++ b/crates/hue/src/api/mod.rs @@ -0,0 +1,410 @@ +mod behavior; +mod device; +mod entertainment; +mod entertainment_config; +mod grouped_light; +mod light; +mod resource; +mod room; +mod scene; +mod stream; +mod stubs; +mod update; +mod zigbee_device_discovery; + +pub use behavior::{ + BehaviorInstance, BehaviorInstanceConfiguration, BehaviorInstanceMetadata, + BehaviorInstanceUpdate, BehaviorScript, BehaviorScriptMetadata, WakeupConfiguration, + WakeupStyle, +}; +pub use device::{Device, DeviceArchetype, DeviceProductData, DeviceUpdate, Identify}; +pub use entertainment::{Entertainment, EntertainmentSegment, EntertainmentSegments}; +pub use entertainment_config::{ + EntertainmentConfiguration, EntertainmentConfigurationAction, + EntertainmentConfigurationChannels, EntertainmentConfigurationLocations, + EntertainmentConfigurationLocationsNew, EntertainmentConfigurationLocationsUpdate, + EntertainmentConfigurationMetadata, EntertainmentConfigurationNew, + EntertainmentConfigurationServiceLocations, EntertainmentConfigurationServiceLocationsNew, + EntertainmentConfigurationServiceLocationsUpdate, EntertainmentConfigurationStatus, + EntertainmentConfigurationStreamMembers, EntertainmentConfigurationStreamProxy, + EntertainmentConfigurationStreamProxyMode, EntertainmentConfigurationStreamProxyUpdate, + EntertainmentConfigurationType, EntertainmentConfigurationUpdate, Position, +}; +pub use grouped_light::{GroupedLight, GroupedLightUpdate}; +pub use light::{ + ColorGamut, ColorTemperature, ColorTemperatureUpdate, ColorUpdate, Delta, Dimming, + DimmingUpdate, GamutType, Light, LightAlert, LightColor, LightDynamics, LightDynamicsStatus, + LightEffect, LightEffectActionUpdate, LightEffectParameters, LightEffectStatus, + LightEffectValues, LightEffects, LightEffectsV2, LightEffectsV2Update, LightFunction, + LightGradient, LightGradientMode, LightGradientPoint, LightGradientUpdate, LightMetadata, + LightMode, LightPowerup, LightPowerupColor, LightPowerupDimming, LightPowerupOn, + LightPowerupPreset, LightProductData, LightSignal, LightSignaling, LightTimedEffect, + LightTimedEffects, LightTimedEffectsUpdate, LightUpdate, MirekSchema, On, +}; +pub use resource::{RType, ResourceLink, ResourceRecord}; +pub use room::{Room, RoomArchetype, RoomMetadata, RoomMetadataUpdate, RoomUpdate}; +pub use scene::{ + Scene, SceneAction, SceneActionElement, SceneActive, SceneMetadata, SceneRecall, SceneStatus, + SceneStatusEnum, SceneUpdate, +}; +use serde::ser::SerializeMap; +pub use stream::HueStreamKey; +pub use stubs::{ + Bridge, BridgeHome, Button, ButtonData, ButtonMetadata, ButtonReport, DevicePower, + DeviceSoftwareUpdate, DollarRef, GeofenceClient, Geolocation, GroupedLightLevel, GroupedMotion, + Homekit, LightLevel, Matter, Metadata, MetadataUpdate, Motion, PrivateGroup, PublicImage, + RelativeRotary, SmartScene, Taurus, Temperature, TimeZone, ZigbeeConnectivity, + ZigbeeConnectivityStatus, Zone, +}; +pub use update::Update; +pub use zigbee_device_discovery::{ + ZigbeeDeviceDiscovery, ZigbeeDeviceDiscoveryAction, ZigbeeDeviceDiscoveryInstallCode, + ZigbeeDeviceDiscoveryStatus, ZigbeeDeviceDiscoveryUpdate, ZigbeeDeviceDiscoveryUpdateAction, + ZigbeeDeviceDiscoveryUpdateActionType, +}; + +use std::fmt::Debug; + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, from_value, json}; + +use crate::error::{HueError, HueResult}; +use crate::legacy_api::ApiLightStateUpdate; + +#[derive(Debug, Deserialize, Clone, Default, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Stub; + +impl Serialize for Stub { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_map(None)?.end() + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Resource { + AuthV1(ResourceLink), + BehaviorInstance(BehaviorInstance), + BehaviorScript(BehaviorScript), + Bridge(Bridge), + BridgeHome(BridgeHome), + Button(Button), + Device(Device), + DevicePower(DevicePower), + DeviceSoftwareUpdate(DeviceSoftwareUpdate), + Entertainment(Entertainment), + EntertainmentConfiguration(EntertainmentConfiguration), + GeofenceClient(GeofenceClient), + Geolocation(Geolocation), + GroupedLight(GroupedLight), + GroupedLightLevel(GroupedLightLevel), + GroupedMotion(GroupedMotion), + Homekit(Homekit), + Light(Light), + LightLevel(LightLevel), + Matter(Matter), + Motion(Motion), + PrivateGroup(PrivateGroup), + PublicImage(PublicImage), + RelativeRotary(RelativeRotary), + Room(Room), + Scene(Scene), + SmartScene(SmartScene), + #[serde(rename = "taurus_7455")] + Taurus(Taurus), + Temperature(Temperature), + ZigbeeConnectivity(ZigbeeConnectivity), + ZigbeeDeviceDiscovery(ZigbeeDeviceDiscovery), + Zone(Zone), + + /* Unmapped variants */ + CameraMotion(Value), + Contact(Value), + MatterFabric(Value), + ServiceGroup(Value), + Tamper(Value), + ZgpConnectivity(Value), +} + +impl Resource { + #[must_use] + pub const fn rtype(&self) -> RType { + match self { + Self::AuthV1(_) => RType::AuthV1, + Self::BehaviorInstance(_) => RType::BehaviorInstance, + Self::BehaviorScript(_) => RType::BehaviorScript, + Self::Bridge(_) => RType::Bridge, + Self::BridgeHome(_) => RType::BridgeHome, + Self::Button(_) => RType::Button, + Self::CameraMotion(_) => RType::CameraMotion, + Self::Contact(_) => RType::Contact, + Self::Device(_) => RType::Device, + Self::DevicePower(_) => RType::DevicePower, + Self::DeviceSoftwareUpdate(_) => RType::DeviceSoftwareUpdate, + Self::Entertainment(_) => RType::Entertainment, + Self::EntertainmentConfiguration(_) => RType::EntertainmentConfiguration, + Self::GeofenceClient(_) => RType::GeofenceClient, + Self::Geolocation(_) => RType::Geolocation, + Self::GroupedLight(_) => RType::GroupedLight, + Self::GroupedLightLevel(_) => RType::GroupedLightLevel, + Self::GroupedMotion(_) => RType::GroupedMotion, + Self::Homekit(_) => RType::Homekit, + Self::Light(_) => RType::Light, + Self::LightLevel(_) => RType::LightLevel, + Self::Matter(_) => RType::Matter, + Self::MatterFabric(_) => RType::MatterFabric, + Self::Motion(_) => RType::Motion, + Self::PrivateGroup(_) => RType::PrivateGroup, + Self::PublicImage(_) => RType::PublicImage, + Self::RelativeRotary(_) => RType::RelativeRotary, + Self::Room(_) => RType::Room, + Self::Scene(_) => RType::Scene, + Self::ServiceGroup(_) => RType::ServiceGroup, + Self::SmartScene(_) => RType::SmartScene, + Self::Tamper(_) => RType::Tamper, + Self::Taurus(_) => RType::Taurus, + Self::Temperature(_) => RType::Temperature, + Self::ZgpConnectivity(_) => RType::ZgpConnectivity, + Self::ZigbeeConnectivity(_) => RType::ZigbeeConnectivity, + Self::ZigbeeDeviceDiscovery(_) => RType::ZigbeeDeviceDiscovery, + Self::Zone(_) => RType::Zone, + } + } + + #[allow(clippy::match_same_arms)] + #[must_use] + pub const fn owner(&self) -> Option { + match self { + Self::AuthV1(_) => None, + Self::BehaviorInstance(_) => None, + Self::BehaviorScript(_) => None, + Self::Bridge(obj) => Some(obj.owner), + Self::BridgeHome(_) => None, + Self::Button(obj) => Some(obj.owner), + Self::Device(_) => None, + Self::DevicePower(obj) => Some(obj.owner), + Self::DeviceSoftwareUpdate(obj) => Some(obj.owner), + Self::Entertainment(obj) => Some(obj.owner), + Self::EntertainmentConfiguration(_) => None, + Self::GeofenceClient(_) => None, + Self::Geolocation(_) => None, + Self::GroupedLight(obj) => Some(obj.owner), + Self::GroupedLightLevel(obj) => Some(obj.owner), + Self::GroupedMotion(obj) => Some(obj.owner), + Self::Homekit(_) => None, + Self::Light(obj) => Some(obj.owner), + Self::LightLevel(obj) => Some(obj.owner), + Self::Matter(_) => None, + Self::Motion(obj) => Some(obj.owner), + Self::PrivateGroup(_) => None, + Self::PublicImage(_) => None, + Self::RelativeRotary(obj) => Some(obj.owner), + Self::Room(_) => None, + Self::Scene(_) => None, + Self::SmartScene(_) => None, + Self::Taurus(obj) => Some(obj.owner), + Self::Temperature(obj) => Some(obj.owner), + Self::ZigbeeConnectivity(obj) => Some(obj.owner), + Self::ZigbeeDeviceDiscovery(obj) => Some(obj.owner), + Self::Zone(_) => None, + + /* Unmapped variants */ + Self::CameraMotion(_) => None, + Self::Contact(_) => None, + Self::MatterFabric(_) => None, + Self::ServiceGroup(_) => None, + Self::Tamper(_) => None, + Self::ZgpConnectivity(_) => None, + } + } + + pub fn from_value(rtype: RType, obj: Value) -> HueResult { + let res = match rtype { + RType::AuthV1 => Self::AuthV1(from_value(obj)?), + RType::BehaviorInstance => Self::BehaviorInstance(from_value(obj)?), + RType::BehaviorScript => Self::BehaviorScript(from_value(obj)?), + RType::Bridge => Self::Bridge(from_value(obj)?), + RType::BridgeHome => Self::BridgeHome(from_value(obj)?), + RType::Button => Self::Button(from_value(obj)?), + RType::Device => Self::Device(from_value(obj)?), + RType::DevicePower => Self::DevicePower(from_value(obj)?), + RType::DeviceSoftwareUpdate => Self::DeviceSoftwareUpdate(from_value(obj)?), + RType::Entertainment => Self::Entertainment(from_value(obj)?), + RType::EntertainmentConfiguration => Self::EntertainmentConfiguration(from_value(obj)?), + RType::GeofenceClient => Self::GeofenceClient(from_value(obj)?), + RType::Geolocation => Self::Geolocation(from_value(obj)?), + RType::GroupedLight => Self::GroupedLight(from_value(obj)?), + RType::GroupedLightLevel => Self::GroupedLightLevel(from_value(obj)?), + RType::GroupedMotion => Self::GroupedMotion(from_value(obj)?), + RType::Homekit => Self::Homekit(from_value(obj)?), + RType::Light => Self::Light(from_value(obj)?), + RType::LightLevel => Self::LightLevel(from_value(obj)?), + RType::Matter => Self::Matter(from_value(obj)?), + RType::Motion => Self::Motion(from_value(obj)?), + RType::PrivateGroup => Self::PrivateGroup(from_value(obj)?), + RType::PublicImage => Self::PublicImage(from_value(obj)?), + RType::RelativeRotary => Self::RelativeRotary(from_value(obj)?), + RType::Room => Self::Room(from_value(obj)?), + RType::Scene => Self::Scene(from_value(obj)?), + RType::SmartScene => Self::SmartScene(from_value(obj)?), + RType::Taurus => Self::Taurus(from_value(obj)?), + RType::Temperature => Self::Temperature(from_value(obj)?), + RType::ZigbeeConnectivity => Self::ZigbeeConnectivity(from_value(obj)?), + RType::ZigbeeDeviceDiscovery => Self::ZigbeeDeviceDiscovery(from_value(obj)?), + RType::Zone => Self::Zone(from_value(obj)?), + RType::CameraMotion => Self::CameraMotion(obj), + RType::Contact => Self::Contact(obj), + RType::MatterFabric => Self::MatterFabric(obj), + RType::ServiceGroup => Self::ServiceGroup(obj), + RType::Tamper => Self::Tamper(obj), + RType::ZgpConnectivity => Self::ZgpConnectivity(obj), + }; + Ok(res) + } +} + +#[macro_export] +macro_rules! resource_conversion_impl { + ( $name:ident ) => { + impl<'a> TryFrom<&'a mut Resource> for &'a mut $name { + type Error = HueError; + + fn try_from(value: &'a mut Resource) -> Result { + if let Resource::$name(obj) = value { + Ok(obj) + } else { + Err(HueError::WrongType(RType::Light, value.rtype())) + } + } + } + + impl<'a> TryFrom<&'a Resource> for &'a $name { + type Error = HueError; + + fn try_from(value: &'a Resource) -> Result { + if let Resource::$name(obj) = value { + Ok(obj) + } else { + Err(HueError::WrongType(RType::Light, value.rtype())) + } + } + } + + impl TryFrom for $name { + type Error = HueError; + + fn try_from(value: Resource) -> Result { + if let Resource::$name(obj) = value { + Ok(obj) + } else { + Err(HueError::WrongType(RType::Light, value.rtype())) + } + } + } + + impl From<$name> for Resource { + fn from(value: $name) -> Self { + Resource::$name(value) + } + } + }; +} + +// AuthV1 is not a real resource (only used in links) +// resource_conversion_impl!(AuthV1); +resource_conversion_impl!(BehaviorInstance); +resource_conversion_impl!(BehaviorScript); +resource_conversion_impl!(Bridge); +resource_conversion_impl!(BridgeHome); +resource_conversion_impl!(Button); +resource_conversion_impl!(Device); +resource_conversion_impl!(DevicePower); +resource_conversion_impl!(DeviceSoftwareUpdate); +resource_conversion_impl!(Entertainment); +resource_conversion_impl!(EntertainmentConfiguration); +resource_conversion_impl!(GeofenceClient); +resource_conversion_impl!(Geolocation); +resource_conversion_impl!(GroupedLight); +resource_conversion_impl!(GroupedLightLevel); +resource_conversion_impl!(GroupedMotion); +resource_conversion_impl!(Homekit); +resource_conversion_impl!(Light); +resource_conversion_impl!(LightLevel); +resource_conversion_impl!(Matter); +resource_conversion_impl!(Motion); +resource_conversion_impl!(PrivateGroup); +resource_conversion_impl!(PublicImage); +resource_conversion_impl!(RelativeRotary); +resource_conversion_impl!(Room); +resource_conversion_impl!(Scene); +resource_conversion_impl!(SmartScene); +resource_conversion_impl!(Taurus); +resource_conversion_impl!(Temperature); +resource_conversion_impl!(ZigbeeConnectivity); +resource_conversion_impl!(ZigbeeDeviceDiscovery); +resource_conversion_impl!(Zone); + +#[derive(Clone, Debug, Serialize)] +pub struct V1Reply<'a> { + prefix: String, + success: Vec<(&'a str, Value)>, +} + +impl<'a> V1Reply<'a> { + #[must_use] + pub const fn new(prefix: String) -> Self { + Self { + prefix, + success: vec![], + } + } + + #[must_use] + pub fn for_light(id: u32, path: &str) -> Self { + Self::new(format!("/lights/{id}/{path}")) + } + + #[must_use] + pub fn for_group_path(id: u32, path: &str) -> Self { + Self::new(format!("/groups/{id}/{path}")) + } + + #[must_use] + pub fn for_group(id: u32) -> Self { + Self::new(format!("/groups/{id}")) + } + + pub fn with_light_state_update(self, upd: &ApiLightStateUpdate) -> HueResult { + self.add_option("on", upd.on)? + .add_option("bri", upd.bri)? + .add_option("xy", upd.xy)? + .add_option("ct", upd.ct)? + .add_option("transitiontime", upd.transitiontime) + } + + pub fn add(mut self, name: &'a str, value: T) -> HueResult { + self.success.push((name, serde_json::to_value(value)?)); + Ok(self) + } + + pub fn add_option(mut self, name: &'a str, value: Option) -> HueResult { + if let Some(val) = value { + self.success.push((name, serde_json::to_value(val)?)); + } + Ok(self) + } + + #[must_use] + pub fn json(self) -> Value { + let mut json = vec![]; + let prefix = self.prefix; + for (name, value) in self.success { + json.push(json!({"success": {format!("{prefix}/{name}"): value}})); + } + json!(json) + } +} diff --git a/crates/hue/src/api/resource.rs b/crates/hue/src/api/resource.rs new file mode 100644 index 0000000..053793a --- /dev/null +++ b/crates/hue/src/api/resource.rs @@ -0,0 +1,220 @@ +use std::fmt::{self, Debug}; +use std::hash::{Hash, Hasher}; + +use serde::{Deserialize, Serialize}; +use siphasher::sip::SipHasher13; +use uuid::Uuid; + +use crate::api::Resource; + +#[derive(Copy, Debug, Serialize, Deserialize, Clone, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "snake_case")] +pub enum RType { + /// Only used in [`ResourceLink`] references + AuthV1, + BehaviorInstance, + BehaviorScript, + Bridge, + BridgeHome, + Button, + CameraMotion, + Contact, + Device, + DevicePower, + DeviceSoftwareUpdate, + Entertainment, + EntertainmentConfiguration, + GeofenceClient, + Geolocation, + GroupedLight, + GroupedLightLevel, + GroupedMotion, + Homekit, + Light, + LightLevel, + Matter, + MatterFabric, + Motion, + /// Only used in [`ResourceLink`] references + PrivateGroup, + /// Only used in [`ResourceLink`] references + PublicImage, + RelativeRotary, + Room, + Scene, + ServiceGroup, + SmartScene, + #[serde(rename = "taurus_7455")] + Taurus, + Tamper, + Temperature, + ZgpConnectivity, + ZigbeeConnectivity, + ZigbeeDeviceDiscovery, + Zone, +} + +/// Manually implement Hash, so any future additions/reordering of [`RType`] +/// does not affect output of [`RType::deterministic()`] +impl Hash for RType { + fn hash(&self, state: &mut H) { + // these are all set in stone! + // + // never change any of these assignments. + // + // use a new unique number for future variants + let index: u64 = match self { + Self::AuthV1 => 0, + Self::BehaviorInstance => 1, + Self::BehaviorScript => 2, + Self::Bridge => 3, + Self::BridgeHome => 4, + Self::Button => 5, + Self::Device => 6, + Self::DevicePower => 7, + Self::DeviceSoftwareUpdate => 8, + Self::Entertainment => 9, + Self::EntertainmentConfiguration => 10, + Self::GeofenceClient => 11, + Self::Geolocation => 12, + Self::GroupedLight => 13, + Self::GroupedLightLevel => 14, + Self::GroupedMotion => 15, + Self::Homekit => 16, + Self::Light => 17, + Self::LightLevel => 18, + Self::Matter => 19, + Self::Motion => 20, + Self::PrivateGroup => 21, + Self::PublicImage => 22, + Self::RelativeRotary => 23, + Self::Room => 24, + Self::Scene => 25, + Self::SmartScene => 26, + Self::Taurus => 27, + Self::Temperature => 28, + Self::ZigbeeConnectivity => 29, + Self::ZigbeeDeviceDiscovery => 30, + Self::Zone => 31, + + /* Added later, so not sorted alphabetically */ + Self::CameraMotion => 32, + Self::Contact => 33, + Self::MatterFabric => 34, + Self::ServiceGroup => 35, + Self::Tamper => 36, + Self::ZgpConnectivity => 37, + }; + + index.hash(state); + } +} + +fn hash(t: &T) -> u64 { + let mut s = SipHasher13::new(); + t.hash(&mut s); + s.finish() +} + +impl RType { + #[must_use] + pub const fn link_to(self, rid: Uuid) -> ResourceLink { + ResourceLink { rid, rtype: self } + } + + #[must_use] + pub fn deterministic(self, data: impl Hash) -> ResourceLink { + /* hash resource type (i.e., self) */ + let h1 = hash(&self); + + /* hash data */ + let h2 = hash(&data); + + /* use resulting bytes for uuid seed */ + let seed: &[u8] = &[h1.to_le_bytes(), h2.to_le_bytes()].concat(); + + let rid = Uuid::new_v5(&Uuid::NAMESPACE_OID, seed); + + self.link_to(rid) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ResourceRecord { + pub id: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub id_v1: Option, + #[serde(flatten)] + pub obj: Resource, +} + +impl ResourceRecord { + #[must_use] + pub const fn new(id: Uuid, id_v1: Option, obj: Resource) -> Self { + Self { id, id_v1, obj } + } +} + +#[derive(Copy, Hash, Serialize, Deserialize, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct ResourceLink { + pub rid: Uuid, + pub rtype: RType, +} + +impl ResourceLink { + #[must_use] + pub const fn new(rid: Uuid, rtype: RType) -> Self { + Self { rid, rtype } + } +} + +impl Debug for ResourceLink { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // we need serde(rename_all = "snake_case") translation + let rtype = serde_json::to_string(&self.rtype).unwrap(); + let rid = self.rid; + write!(f, "{}/{rid}", rtype.trim_matches('"')) + } +} + +#[cfg(test)] +mod tests { + use uuid::uuid; + + use crate::api::RType; + + #[test] + fn rlink_hash_uses_input() { + let a = RType::Room.deterministic("foo"); + let b = RType::Room.deterministic("bar"); + + // these must be different - otherwise we forgot to use input + assert_ne!(a, b); + } + + #[test] + fn rlink_hash_uses_rtype() { + let a = RType::Room.deterministic("foo"); + let b = RType::Scene.deterministic("foo"); + + // these must be different - otherwise we forgot to use type + assert_ne!(a, b); + } + + macro_rules! assert_hash { + ($rtype:path, $uuid:expr) => { + assert_eq!($rtype.deterministic("foo").rid, uuid!($uuid)); + }; + } + + #[test] + fn rlink_hash_deterministic() { + assert_hash!(RType::AuthV1, "9c9dc594-12c4-5db8-bc01-3bd26c09cf0f"); + assert_hash!(RType::Device, "fa83ad4c-fbd8-519c-b543-d7aaf2041c75"); + assert_hash!(RType::Light, "020d5289-53f8-5051-ac97-7ea60043223e"); + assert_hash!(RType::Room, "03585677-7f50-5379-b7a6-8c4d70d63c67"); + assert_hash!(RType::GroupedLight, "b2126c4a-16e3-59f4-b11f-4c674c9130f5"); + assert_hash!(RType::Scene, "02808610-c1ec-5774-8eaf-453b83cf1981"); + assert_hash!(RType::Zone, "1cc85d96-7bb6-5e75-938c-df4207136480"); + } +} diff --git a/crates/hue/src/api/room.rs b/crates/hue/src/api/room.rs new file mode 100644 index 0000000..3703595 --- /dev/null +++ b/crates/hue/src/api/room.rs @@ -0,0 +1,196 @@ +use std::collections::BTreeSet; +use std::ops::{AddAssign, Sub}; + +use serde::{Deserialize, Serialize}; + +use crate::api::{RType, ResourceLink}; + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct RoomMetadata { + pub name: String, + pub archetype: RoomArchetype, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct RoomMetadataUpdate { + pub name: Option, + pub archetype: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct Room { + pub children: BTreeSet, + pub metadata: RoomMetadata, + #[serde(default)] + pub services: BTreeSet, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct RoomUpdate { + #[serde(skip_serializing_if = "Option::is_none")] + pub children: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub services: Option>, +} + +impl Room { + #[must_use] + pub fn grouped_light_service(&self) -> Option<&ResourceLink> { + self.services + .iter() + .find(|rl| rl.rtype == RType::GroupedLight) + } +} + +impl RoomUpdate { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub fn with_metadata(self, metadata: RoomMetadata) -> Self { + Self { + metadata: Some(RoomMetadataUpdate { + name: Some(metadata.name), + archetype: Some(metadata.archetype), + }), + ..self + } + } + + #[must_use] + pub fn with_children(self, children: BTreeSet) -> Self { + Self { + children: Some(children), + ..self + } + } +} + +#[derive(Copy, Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RoomArchetype { + LivingRoom, + Kitchen, + Dining, + Bedroom, + KidsBedroom, + Bathroom, + Nursery, + Office, + GuestRoom, + + Toilet, + Staircase, + Hallway, + LaundryRoom, + Storage, + Closet, + Garage, + Other, + + Gym, + Lounge, + Tv, + Computer, + Recreation, + /// Gaming Room + ManCave, + Music, + /// Library + Reading, + Studio, + + /// Backyard + Garden, + /// Patio + Terrace, + Balcony, + Driveway, + Carport, + FrontDoor, + Porch, + Barbecue, + Pool, + + Downstairs, + Upstairs, + TopFloor, + Attic, + Home, +} + +impl RoomMetadata { + #[must_use] + pub fn new(archetype: RoomArchetype, name: &str) -> Self { + Self { + archetype, + name: name.to_string(), + } + } +} + +impl AddAssign<&RoomUpdate> for Room { + fn add_assign(&mut self, rhs: &RoomUpdate) { + if let Some(md) = &rhs.metadata { + self.metadata += md; + } + if let Some(children) = &rhs.children { + self.children.clone_from(children); + } + } +} + +impl AddAssign<&RoomMetadataUpdate> for RoomMetadata { + fn add_assign(&mut self, upd: &RoomMetadataUpdate) { + if let Some(name) = &upd.name { + self.name.clone_from(name); + } + if let Some(archetype) = &upd.archetype { + self.archetype = *archetype; + } + } +} + +#[allow(clippy::if_not_else)] +impl Sub<&RoomMetadata> for &RoomMetadata { + type Output = RoomMetadataUpdate; + + fn sub(self, rhs: &RoomMetadata) -> Self::Output { + let mut upd = Self::Output::default(); + + if self != rhs { + if self.name != rhs.name { + upd.name = Some(rhs.name.clone()); + } + if self.archetype != rhs.archetype { + upd.archetype = Some(rhs.archetype); + } + } + + upd + } +} + +#[allow(clippy::if_not_else)] +impl Sub<&Room> for &Room { + type Output = RoomUpdate; + + fn sub(self, rhs: &Room) -> Self::Output { + let mut upd = Self::Output::default(); + + if self != rhs { + if self.children != rhs.children { + upd.children = Some(rhs.children.clone()); + } + if self.metadata != rhs.metadata { + upd.metadata = Some(&self.metadata - &rhs.metadata); + } + } + + upd + } +} diff --git a/crates/hue/src/api/scene.rs b/crates/hue/src/api/scene.rs new file mode 100644 index 0000000..f31e839 --- /dev/null +++ b/crates/hue/src/api/scene.rs @@ -0,0 +1,218 @@ +use std::ops::{AddAssign, Sub}; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::api::{ + ColorTemperatureUpdate, ColorUpdate, DimmingUpdate, LightGradientUpdate, On, ResourceLink, +}; +use crate::date_format; + +#[derive(Copy, Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SceneActive { + Inactive, + Static, + DynamicPalette, +} + +#[derive(Copy, Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct SceneStatus { + pub active: SceneActive, + #[serde( + with = "date_format::utc_ms_opt", + default, + skip_serializing_if = "Option::is_none" + )] + pub last_recall: Option>, +} + +#[derive(Copy, Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SceneStatusEnum { + Active, + Static, + DynamicPalette, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Scene { + pub actions: Vec, + #[serde(default)] + pub auto_dynamic: bool, + pub group: ResourceLink, + pub metadata: SceneMetadata, + /* palette: { */ + /* color: [], */ + /* color_temperature: [ */ + /* { */ + /* color_temperature: { */ + /* mirek: u32 */ + /* }, */ + /* dimming: { */ + /* brightness: f64, */ + /* } */ + /* } */ + /* ], */ + /* dimming: [], */ + /* effects: [] */ + /* }, */ + #[serde(default, skip_serializing_if = "Value::is_null")] + pub palette: Value, + #[serde(default)] + pub speed: f64, + pub status: Option, + #[serde(default)] + pub recall: SceneRecall, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct SceneAction { + #[serde(skip_serializing_if = "Option::is_none")] + pub color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub color_temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub dimming: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub on: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub gradient: Option, + #[serde(default, skip_serializing_if = "Value::is_null")] + pub effects: Value, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct SceneActionElement { + pub action: SceneAction, + pub target: ResourceLink, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct SceneMetadata { + #[serde(skip_serializing_if = "Option::is_none")] + pub appdata: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub image: Option, + pub name: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)] +pub struct SceneMetadataUpdate { + #[serde(skip_serializing_if = "Option::is_none")] + pub appdata: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub image: Option, + pub name: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct SceneUpdate { + #[serde(skip_serializing_if = "Option::is_none")] + pub actions: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub recall: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub palette: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub speed: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_dynamic: Option, +} + +impl SceneUpdate { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub fn with_actions(self, actions: Option>) -> Self { + Self { actions, ..self } + } + + #[must_use] + pub fn with_recall_action(self, action: Option) -> Self { + Self { + recall: Some(SceneRecall { + action: match action.map(|a| a.active) { + Some(SceneActive::DynamicPalette) => Some(SceneStatusEnum::DynamicPalette), + Some(SceneActive::Static) => Some(SceneStatusEnum::Active), + Some(SceneActive::Inactive) | None => None, + }, + duration: None, + dimming: None, + }), + ..self + } + } +} + +impl AddAssign<&SceneUpdate> for Scene { + fn add_assign(&mut self, upd: &SceneUpdate) { + if let Some(actions) = &upd.actions { + self.actions.clone_from(actions); + } + if let Some(md) = &upd.metadata { + self.metadata += md; + } + if let Some(palette) = &upd.palette { + self.palette.clone_from(palette); + } + if let Some(speed) = upd.speed { + self.speed = speed; + } + if let Some(auto_dynamic) = upd.auto_dynamic { + self.auto_dynamic = auto_dynamic; + } + } +} + +impl AddAssign<&SceneMetadataUpdate> for SceneMetadata { + fn add_assign(&mut self, upd: &SceneMetadataUpdate) { + if let Some(appdata) = &upd.appdata { + self.appdata = Some(appdata.to_string()); + } + if let Some(image) = &upd.image { + self.image = Some(*image); + } + if let Some(name) = &upd.name { + self.name.clone_from(name); + } + } +} + +impl Sub<&SceneMetadata> for &SceneMetadata { + type Output = SceneMetadataUpdate; + + fn sub(self, rhs: &SceneMetadata) -> Self::Output { + let mut upd = Self::Output::default(); + + if self != rhs { + if self.appdata != rhs.appdata { + upd.appdata.clone_from(&rhs.appdata); + } + if self.image != rhs.image { + upd.image.clone_from(&rhs.image); + } + if self.name != rhs.name { + upd.name = Some(rhs.name.clone()); + } + } + + upd + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct SceneRecall { + #[serde(skip_serializing_if = "Option::is_none")] + pub action: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub dimming: Option, +} diff --git a/crates/hue/src/api/stream.rs b/crates/hue/src/api/stream.rs new file mode 100644 index 0000000..23ba1c7 --- /dev/null +++ b/crates/hue/src/api/stream.rs @@ -0,0 +1,58 @@ +use hex::FromHexError; + +use crate::error::{HueError, HueResult}; + +pub struct HueStreamKey { + key: [u8; Self::BYTE_SIZE], +} + +impl HueStreamKey { + const BYTE_SIZE: usize = 16; + const HEX_SIZE: usize = Self::BYTE_SIZE * 2; + + #[must_use] + pub const fn new(key: [u8; Self::BYTE_SIZE]) -> Self { + Self { key } + } + + pub fn write_to_slice(&self, out: &mut [u8]) -> HueResult<()> { + if out.len() < Self::BYTE_SIZE { + return Err(HueError::FromHexError(FromHexError::InvalidStringLength)); + } + out[..Self::BYTE_SIZE].copy_from_slice(&self.key); + Ok(()) + } + + #[must_use] + pub fn to_hex(&self) -> String { + hex::encode(self.key) + } + + pub fn to_hex_slice(&self, out: &mut [u8]) -> HueResult<()> { + if out.len() < Self::HEX_SIZE { + return Err(HueError::FromHexError(FromHexError::InvalidStringLength)); + } + Ok(hex::encode_to_slice(self.key, &mut out[..Self::HEX_SIZE])?) + } +} + +impl AsRef<[u8]> for HueStreamKey { + fn as_ref(&self) -> &[u8] { + &self.key + } +} + +impl TryFrom<&str> for HueStreamKey { + type Error = HueError; + + fn try_from(value: &str) -> Result { + let mut key = [0u8; 16]; + if value.len() < Self::HEX_SIZE { + return Err(HueError::FromHexError(FromHexError::InvalidStringLength)); + } + + hex::decode_to_slice(value, &mut key)?; + + Ok(Self::new(key)) + } +} diff --git a/crates/hue/src/api/stubs.rs b/crates/hue/src/api/stubs.rs new file mode 100644 index 0000000..092d6b6 --- /dev/null +++ b/crates/hue/src/api/stubs.rs @@ -0,0 +1,249 @@ +use std::collections::BTreeSet; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::api::{DeviceArchetype, LightFunction, ResourceLink, SceneMetadata}; +use crate::{best_guess_timezone, date_format}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Bridge { + pub bridge_id: String, + pub owner: ResourceLink, + pub time_zone: TimeZone, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct BridgeHome { + pub children: BTreeSet, + pub services: BTreeSet, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Button { + pub owner: ResourceLink, + pub metadata: ButtonMetadata, + pub button: ButtonData, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ButtonMetadata { + pub control_id: u32, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ButtonData { + #[serde(skip_serializing_if = "Option::is_none")] + pub button_report: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_event: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub repeat_interval: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub event_values: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ButtonReport { + #[serde(with = "date_format::utc_ms")] + pub updated: DateTime, + pub event: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct DollarRef { + #[serde(rename = "$ref", skip_serializing_if = "Option::is_none")] + pub dref: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct DevicePower { + pub owner: ResourceLink, + pub power_state: Value, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct DeviceSoftwareUpdate { + pub owner: ResourceLink, + pub state: Value, + pub problems: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct GeofenceClient { + pub name: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Geolocation { + pub is_configured: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub sun_today: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct GroupedMotion { + pub owner: ResourceLink, + pub enabled: bool, + pub motion: Value, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct GroupedLightLevel { + pub owner: ResourceLink, + pub enabled: bool, + #[serde(default)] + pub light: Value, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Homekit { + pub status: String, + pub status_values: Vec, +} + +impl Default for Homekit { + fn default() -> Self { + Self { + status: "unpaired".to_string(), + status_values: vec![ + "pairing".to_string(), + "paired".to_string(), + "unpaired".to_string(), + ], + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct LightLevel { + pub enabled: bool, + pub light: Value, + pub owner: ResourceLink, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Matter { + pub has_qr_code: bool, + pub max_fabrics: u32, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Motion { + pub enabled: bool, + pub owner: ResourceLink, + pub motion: Value, + #[serde(default)] + pub sensitivity: Value, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct PrivateGroup {} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct PublicImage {} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct RelativeRotary { + pub owner: ResourceLink, + #[serde(skip_serializing_if = "Option::is_none")] + pub relative_rotary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub rotary_report: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct SmartScene { + /* active_timeslot: { */ + /* timeslot_id: 3, */ + /* weekday: monday */ + /* }, */ + #[serde(default)] + #[serde(skip_serializing_if = "Value::is_null")] + pub active_timeslot: Value, + pub group: ResourceLink, + pub metadata: SceneMetadata, + pub state: String, + pub transition_duration: u32, + pub week_timeslots: Value, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Taurus { + pub capabilities: Vec, + pub owner: ResourceLink, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "snake_case")] +pub enum ZigbeeConnectivityStatus { + Connected, + ConnectivityIssue, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ZigbeeConnectivity { + #[serde(skip_serializing_if = "Option::is_none")] + pub channel: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub extended_pan_id: Option, + pub mac_address: String, + pub owner: ResourceLink, + pub status: ZigbeeConnectivityStatus, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Zone { + pub metadata: Metadata, + pub children: BTreeSet, + #[serde(default)] + pub services: BTreeSet, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Temperature { + pub enabled: bool, + pub owner: ResourceLink, + pub temperature: Value, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct TimeZone { + pub time_zone: String, +} + +impl TimeZone { + #[must_use] + pub fn best_guess() -> Self { + Self { + time_zone: best_guess_timezone(), + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct Metadata { + pub name: String, + pub archetype: DeviceArchetype, +} + +impl Metadata { + #[must_use] + pub fn new(archetype: DeviceArchetype, name: &str) -> Self { + Self { + archetype, + name: name.to_string(), + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct MetadataUpdate { + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub archetype: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub function: Option, +} diff --git a/crates/hue/src/api/update.rs b/crates/hue/src/api/update.rs new file mode 100644 index 0000000..b0b1ee5 --- /dev/null +++ b/crates/hue/src/api/update.rs @@ -0,0 +1,61 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::api::{ + BehaviorInstanceUpdate, DeviceUpdate, EntertainmentConfigurationUpdate, GroupedLightUpdate, + LightUpdate, RType, RoomUpdate, SceneUpdate, +}; + +type BridgeUpdate = Value; +type BridgeHomeUpdate = Value; +type ZigbeeDeviceDiscoveryUpdate = Value; +type SmartSceneUpdate = Value; +type ZoneUpdate = Value; +type GeolocationUpdate = Value; + +#[allow(clippy::large_enum_variant)] +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Update { + /* BehaviorScript(BehaviorScriptUpdate), */ + BehaviorInstance(BehaviorInstanceUpdate), + Bridge(BridgeUpdate), + BridgeHome(BridgeHomeUpdate), + Device(DeviceUpdate), + /* Entertainment(EntertainmentUpdate), */ + EntertainmentConfiguration(EntertainmentConfigurationUpdate), + /* GeofenceClient(GeofenceClientUpdate), */ + Geolocation(GeolocationUpdate), + GroupedLight(GroupedLightUpdate), + /* Homekit(HomekitUpdate), */ + Light(LightUpdate), + /* Matter(MatterUpdate), */ + /* PublicImage(PublicImageUpdate), */ + Room(RoomUpdate), + Scene(SceneUpdate), + SmartScene(SmartSceneUpdate), + /* ZigbeeConnectivity(ZigbeeConnectivityUpdate), */ + ZigbeeDeviceDiscovery(ZigbeeDeviceDiscoveryUpdate), + Zone(ZoneUpdate), +} + +impl Update { + #[must_use] + pub const fn rtype(&self) -> RType { + match self { + Self::BehaviorInstance(_) => RType::BehaviorInstance, + Self::Bridge(_) => RType::Bridge, + Self::BridgeHome(_) => RType::BridgeHome, + Self::Device(_) => RType::Device, + Self::EntertainmentConfiguration(_) => RType::EntertainmentConfiguration, + Self::Geolocation(_) => RType::Geolocation, + Self::GroupedLight(_) => RType::GroupedLight, + Self::Light(_) => RType::Light, + Self::Room(_) => RType::Room, + Self::Scene(_) => RType::Scene, + Self::SmartScene(_) => RType::SmartScene, + Self::ZigbeeDeviceDiscovery(_) => RType::ZigbeeDeviceDiscovery, + Self::Zone(_) => RType::Zone, + } + } +} diff --git a/crates/hue/src/api/zigbee_device_discovery.rs b/crates/hue/src/api/zigbee_device_discovery.rs new file mode 100644 index 0000000..dd8f4c6 --- /dev/null +++ b/crates/hue/src/api/zigbee_device_discovery.rs @@ -0,0 +1,60 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +use crate::api::ResourceLink; + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "snake_case")] +pub enum ZigbeeDeviceDiscoveryStatus { + Active, + Ready, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct ZigbeeDeviceDiscoveryAction { + pub action_type_values: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub search_codes: Vec, +} + +impl ZigbeeDeviceDiscoveryAction { + #[must_use] + pub fn is_empty(&self) -> bool { + self.action_type_values.is_empty() + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ZigbeeDeviceDiscovery { + pub owner: ResourceLink, + pub status: ZigbeeDeviceDiscoveryStatus, + + #[serde(default, skip_serializing_if = "ZigbeeDeviceDiscoveryAction::is_empty")] + pub action: ZigbeeDeviceDiscoveryAction, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ZigbeeDeviceDiscoveryInstallCode { + pub mac_address: String, + pub ic: Uuid, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "snake_case")] +pub enum ZigbeeDeviceDiscoveryUpdateActionType { + Search, + SearchAllowDefaultLinkKey, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ZigbeeDeviceDiscoveryUpdateAction { + pub action_type: ZigbeeDeviceDiscoveryUpdateActionType, + pub search_codes: Option>, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ZigbeeDeviceDiscoveryUpdate { + pub action: ZigbeeDeviceDiscoveryUpdateAction, + pub add_install_code: Option, +} diff --git a/crates/hue/src/clamp.rs b/crates/hue/src/clamp.rs new file mode 100644 index 0000000..752ac0d --- /dev/null +++ b/crates/hue/src/clamp.rs @@ -0,0 +1,97 @@ +pub trait Clamp { + fn unit_to_u8_clamped(self) -> u8; + fn unit_to_u8_clamped_light(self) -> u8; + fn unit_from_u8(value: u8) -> Self; +} + +impl Clamp for f32 { + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + fn unit_to_u8_clamped(self) -> u8 { + (self * 255.0).round().clamp(0.0, 255.0) as u8 + } + + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + fn unit_to_u8_clamped_light(self) -> u8 { + self.mul_add(253.0, 1.0).round().clamp(1.0, 254.0) as u8 + } + + fn unit_from_u8(value: u8) -> Self { + Self::from(value) / 255.0 + } +} + +impl Clamp for f64 { + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + fn unit_to_u8_clamped(self) -> u8 { + (self * 255.0).round().clamp(0.0, 255.0) as u8 + } + + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + fn unit_to_u8_clamped_light(self) -> u8 { + self.mul_add(253.0, 1.0).round().clamp(1.0, 254.0) as u8 + } + + fn unit_from_u8(value: u8) -> Self { + Self::from(value) / 255.0 + } +} + +#[cfg(test)] +mod tests { + use crate::clamp::Clamp; + use crate::{compare, compare_float}; + + #[test] + fn f32_unit_to_u8_clamped() { + assert_eq!((-1.0f32).unit_to_u8_clamped(), 0x00); + assert_eq!(0.0f32.unit_to_u8_clamped(), 0x00); + assert_eq!(0.5f32.unit_to_u8_clamped(), 0x80); + assert_eq!(1.0f32.unit_to_u8_clamped(), 0xFF); + assert_eq!(2.0f32.unit_to_u8_clamped(), 0xFF); + } + + #[test] + fn f64_unit_to_u8_clamped() { + assert_eq!((-1.0f64).unit_to_u8_clamped(), 0x00); + assert_eq!(0.0f64.unit_to_u8_clamped(), 0x00); + assert_eq!(0.5f64.unit_to_u8_clamped(), 0x80); + assert_eq!(1.0f64.unit_to_u8_clamped(), 0xFF); + assert_eq!(2.0f64.unit_to_u8_clamped(), 0xFF); + } + + #[test] + fn f32_unit_to_u8_clamped_light() { + assert_eq!((-1.0f32).unit_to_u8_clamped_light(), 0x01); + assert_eq!(0.0f32.unit_to_u8_clamped_light(), 0x01); + assert_eq!(0.5f32.unit_to_u8_clamped_light(), 0x80); + assert_eq!(1.0f32.unit_to_u8_clamped_light(), 0xFE); + assert_eq!(2.0f32.unit_to_u8_clamped_light(), 0xFE); + } + + #[test] + fn f64_unit_to_u8_clamped_light() { + assert_eq!((-1.0f64).unit_to_u8_clamped_light(), 0x01); + assert_eq!(0.0f64.unit_to_u8_clamped_light(), 0x01); + assert_eq!(0.5f64.unit_to_u8_clamped_light(), 0x80); + assert_eq!(1.0f64.unit_to_u8_clamped_light(), 0xFE); + assert_eq!(2.0f64.unit_to_u8_clamped_light(), 0xFE); + } + + #[test] + fn f32_unit_from_u8() { + compare!(f32::unit_from_u8(0x00), 0.0 / 255.0); + compare!(f32::unit_from_u8(0x01), 1.0 / 255.0); + compare!(f32::unit_from_u8(0x02), 2.0 / 255.0); + compare!(f32::unit_from_u8(0xFE), 254.0 / 255.0); + compare!(f32::unit_from_u8(0xFF), 255.0 / 255.0); + } + + #[test] + fn f64_unit_from_u8() { + compare!(f64::unit_from_u8(0x00), 0.0 / 255.0); + compare!(f64::unit_from_u8(0x01), 1.0 / 255.0); + compare!(f64::unit_from_u8(0x02), 2.0 / 255.0); + compare!(f64::unit_from_u8(0xFE), 254.0 / 255.0); + compare!(f64::unit_from_u8(0xFF), 255.0 / 255.0); + } +} diff --git a/crates/hue/src/colorspace.rs b/crates/hue/src/colorspace.rs new file mode 100644 index 0000000..3a40039 --- /dev/null +++ b/crates/hue/src/colorspace.rs @@ -0,0 +1,259 @@ +// This module is heavily inspired by MIT-licensed code found here: +// +// https://viereck.ch/hue-xy-rgb/ +// +// Original code by Thomas Lochmatter + +use std::ops::{Index, IndexMut}; + +use crate::gamma::GammaCorrection; + +#[derive(Clone, Debug)] +pub struct Matrix3(pub [f64; 3 * 3]); + +impl Matrix3 { + #[must_use] + pub const fn identity() -> Self { + Self([ + 1.0, 0.0, 0.0, // + 0.0, 1.0, 0.0, // + 0.0, 0.0, 1.0, // + ]) + } + + #[must_use] + pub fn inverted(&self) -> Option { + let mut current = self.clone(); + let mut inverse = Self::identity(); + + // Gaussian elimination (part 1) + for i in 0..3 { + // Get the diagonal term + let mut d = current[[i, i]]; + + // If it is 0, there must be at least one row with a non-zero element (otherwise, the matrix is not invertible) + if d == 0.0 { + let mut r = i + 1; + + while r < 3 && (current[[r, i]]).abs() < 1e-10 { + r += 1; + } + + if r == 3 { + return None; + } // i is the rank + + for c in 0..3 { + current[[i, c]] += current[[r, c]]; + inverse[[i, c]] += inverse[[r, c]]; + } + + d = current[[i, i]]; + } + + // Divide the row by the diagonal term + let inv = d.recip(); + for c in 0..3 { + current[[i, c]] *= inv; + inverse[[i, c]] *= inv; + } + + // Divide all subsequent rows with a non-zero coefficient, and subtract the row + for r in i + 1..3 { + let p = current.0[r * 3 + i]; + if p != 0.0 { + for c in 0..3 { + current[[r, c]] -= current[[i, c]] * p; + inverse[[r, c]] -= inverse[[i, c]] * p; + } + } + } + } + + // Gaussian elimination (part 2) + for i in (0..3).rev() { + for r in 0..i { + let d = current[[r, i]]; + for c in 0..3 { + current[[r, c]] -= current[[i, c]] * d; + inverse[[r, c]] -= inverse[[i, c]] * d; + } + } + } + + Some(inverse) + } + + #[allow(clippy::suboptimal_flops)] + #[must_use] + pub fn mult(&self, d: [f64; 3]) -> [f64; 3] { + let m = self.0; + let cx = d[0] * m[0] + d[1] * m[1] + d[2] * m[2]; + let cy = d[0] * m[3] + d[1] * m[4] + d[2] * m[5]; + let cz = d[0] * m[6] + d[1] * m[7] + d[2] * m[8]; + [cx, cy, cz] + } +} + +impl Index<[usize; 2]> for Matrix3 { + type Output = f64; + + fn index(&self, index: [usize; 2]) -> &Self::Output { + &self.0[index[0] * 3 + index[1]] + } +} + +impl IndexMut<[usize; 2]> for Matrix3 { + fn index_mut(&mut self, index: [usize; 2]) -> &mut Self::Output { + &mut self.0[index[0] * 3 + index[1]] + } +} + +pub struct ColorSpace { + pub rgb: Matrix3, + pub xyz: Matrix3, + pub gamma: GammaCorrection, +} + +impl ColorSpace { + #[must_use] + pub fn xyz_to_rgb(&self, x: f64, y: f64, z: f64) -> [f64; 3] { + self.rgb.mult([x, y, z]).map(|q| self.gamma.transform(q)) + } + + #[allow(non_snake_case)] + #[must_use] + pub fn xyy_to_rgb(&self, x: f64, y: f64, Y: f64) -> [f64; 3] { + let z = 1.0 - x - y; + self.xyz_to_rgb((Y / y) * x, Y, (Y / y) * z) + } + + #[must_use] + pub fn rgb_to_xyz(&self, r: f64, g: f64, b: f64) -> [f64; 3] { + self.xyz.mult([r, g, b].map(|q| self.gamma.inverse(q))) + } + + #[allow(clippy::many_single_char_names)] + #[must_use] + pub fn rgb_to_xyy(&self, r: f64, g: f64, b: f64) -> [f64; 3] { + let [cx, cy, cz] = self.rgb_to_xyz(r, g, b); + + let x = cx / (cx + cy + cz); + let y = cy / (cx + cy + cz); + let brightness = cy; + + [x, y, brightness] + } + + #[allow(clippy::many_single_char_names)] + #[must_use] + pub fn find_maximum_y(&self, x: f64, y: f64) -> f64 { + let mut bri = 1.0; + for _ in 0..10 { + let [r, g, b] = self.xyy_to_rgb(x, y, bri); + let max = r.max(g).max(b); + bri /= max; + } + + bri + } + + #[allow(non_snake_case)] + #[must_use] + pub fn xy_to_rgb_color(&self, x: f64, y: f64, brightness: f64) -> [f64; 3] { + let max_Y = self.find_maximum_y(x, y); + self.xyy_to_rgb(x, y, max_Y * brightness / 255.0) + } +} + +/// Wide gamut color space +pub const WIDE: ColorSpace = ColorSpace { + rgb: Matrix3([ + 1.4625, -0.1845, -0.2734, // + -0.5229, 1.4479, 0.0681, // + 0.0346, -0.0958, 1.2875, // + ]), + xyz: Matrix3([ + 0.7164, 0.1010, 0.1468, // + 0.2587, 0.7247, 0.0166, // + 0.0000, 0.0512, 0.7740, // + ]), + gamma: GammaCorrection::NONE, +}; + +/// sRGB color space +pub const SRGB: ColorSpace = ColorSpace { + rgb: Matrix3([ + 3.2401, -1.5370, -0.4983, // + -0.9693, 1.8760, 0.0415, // + 0.0558, -0.2040, 1.0572, // + ]), + xyz: Matrix3([ + 0.4125, 0.3576, 0.1804, // + 0.2127, 0.7152, 0.0722, // + 0.0193, 0.1192, 0.9503, // + ]), + gamma: GammaCorrection::SRGB, +}; + +/// Adobe RGB color space +pub const ADOBE: ColorSpace = ColorSpace { + rgb: Matrix3([ + 2.0416, -0.5652, -0.3447, // + -0.9695, 1.8763, 0.0415, // + 0.0135, -0.1184, 1.0154, // + ]), + xyz: Matrix3([ + 0.5767, 0.1856, 0.1882, // + 0.2974, 0.6273, 0.0753, // + 0.0270, 0.0707, 0.9911, // + ]), + gamma: GammaCorrection::NONE, +}; + +#[cfg(test)] +mod tests { + use std::iter::zip; + + use crate::colorspace::{ADOBE, ColorSpace, Matrix3, SRGB, WIDE}; + use crate::{compare, compare_float, compare_matrix}; + + fn verify_matrix(cs: &ColorSpace) { + let xyz = &cs.xyz; + let rgb = &cs.rgb; + + let xyzi = xyz.inverted().unwrap(); + let rgbi = rgb.inverted().unwrap(); + + compare_matrix!(xyz.0, rgbi.0); + compare_matrix!(rgb.0, xyzi.0); + } + + #[test] + fn iverse_wide() { + verify_matrix(&WIDE); + } + + #[test] + fn iverse_srgb() { + verify_matrix(&SRGB); + } + + #[test] + fn iverse_adobe() { + verify_matrix(&ADOBE); + } + + #[test] + fn invert_identity() { + let ident = Matrix3::identity(); + let inv = ident.inverted().unwrap(); + compare_matrix!(ident.0, inv.0); + } + + #[test] + fn invert_zero() { + let zero = Matrix3([0.0; 9]); + assert!(zero.inverted().is_none()); + } +} diff --git a/crates/hue/src/colortemp.rs b/crates/hue/src/colortemp.rs new file mode 100644 index 0000000..7c8394c --- /dev/null +++ b/crates/hue/src/colortemp.rs @@ -0,0 +1,95 @@ +use crate::xy::XY; + +// compute point on 3rd degree polynomial +fn power3_approx(input: f64, q: [f64; 4]) -> f64 { + q[0].mul_add(input, q[1]) + .mul_add(input, q[2]) + .mul_add(input, q[3]) +} + +/// Convert an input CCT value (Corrected Color Temperature) to XY color coordinates +/// +/// Inspired by this implementation: +/// +/// +/// +/// Algorithm by Kang et. al +/// +/// `Kang2002a`: Kang, B., Moon, O., Hong, C., Lee, H., Cho, B., & Kim, +/// Y. (2002). Design of advanced color: Temperature control system for HDTV +/// applications. Journal of the Korean Physical Society, 41(6), 865-871. +/// +#[rustfmt::skip] +#[must_use] +pub fn cct_to_xy(cct: f64) -> XY { + const X_OVER_ZERO: [f64; 4] = [-0.266_123_90, -0.234_358_90, 0.877_695_60, 0.179_910_00]; + const X_OVER_4000: [f64; 4] = [-3.025_846_90, 2.107_037_90, 0.222_634_70, 0.240_390_00]; + const Y_OVER_ZERO: [f64; 4] = [-1.106_381_40, -1.348_110_20, 2.185_558_32, -0.202_196_83]; + const Y_OVER_2222: [f64; 4] = [-0.954_947_60, -1.374_185_93, 2.091_370_15, -0.167_488_67]; + const Y_OVER_4000: [f64; 4] = [ 3.081_758_00, -5.873_386_70, 3.751_129_97, -0.370_014_83]; + + let mk = 1000.0 / cct; + + let x = if cct <= 4000.0 { + power3_approx(mk, X_OVER_ZERO) + } else { + power3_approx(mk, X_OVER_4000) + }; + + let y = if cct <= 2222.0 { + power3_approx(x, Y_OVER_ZERO) + } else if cct <= 4000.0 { + power3_approx(x, Y_OVER_2222) + } else { + power3_approx(x, Y_OVER_4000) + }; + + XY::new(x, y) +} + +#[cfg(test)] +mod tests { + use crate::colortemp::cct_to_xy; + use crate::xy::XY; + use crate::{compare, compare_float, compare_xy}; + + // Regression tests, sanity checked against kelvin-to-blackbody raditation color + // data found here: + // + // + // + // The values match to 2-3 decimals, which is about what can be expected + // from the approximation used. + + #[test] + fn test2000k() { + let a = cct_to_xy(2000.0); + let b = XY::new(0.5269, 0.4132); + + compare_xy!(a, b); + } + + #[test] + fn test3500k() { + let a = cct_to_xy(3500.0); + let b = XY::new(0.4053, 0.3908); + + compare_xy!(a, b); + } + + #[test] + fn test4200k() { + let a = cct_to_xy(4200.0); + let b = XY::new(0.3720, 0.3713); + + compare_xy!(a, b); + } + + #[test] + fn test6500k() { + let a = cct_to_xy(6500.0); + let b = XY::new(0.3134, 0.3236); + + compare_xy!(a, b); + } +} diff --git a/crates/hue/src/date_format.rs b/crates/hue/src/date_format.rs new file mode 100644 index 0000000..3aa564a --- /dev/null +++ b/crates/hue/src/date_format.rs @@ -0,0 +1,324 @@ +const FORMAT: &str = "%Y-%m-%dT%H:%M:%SZ"; +const FORMAT_MS: &str = "%Y-%m-%dT%H:%M:%S%.3fZ"; +const FORMAT_LOCAL: &str = "%Y-%m-%dT%H:%M:%S"; +const UPDATE_FORMAT: &str = "%+"; + +macro_rules! date_serializer { + ($type:ty, $fmt:expr) => { + pub fn serialize(date: &$type, serializer: S) -> Result + where + S: serde::Serializer, + { + let s = format!("{}", date.format($fmt)); + serializer.serialize_str(&s) + } + }; +} + +macro_rules! date_serializer_opt { + ($type:ty, $fmt:expr) => { + pub fn serialize(date: &Option<$type>, serializer: S) -> Result + where + S: serde::Serializer, + { + match date { + Some(d) => serializer.serialize_str(&format!("{}", d.format($fmt))), + None => serializer.serialize_none(), + } + } + }; +} + +macro_rules! date_deserializer_utc { + ($type:ty, $fmt:expr) => { + pub fn deserialize<'de, D>(deserializer: D) -> Result<$type, D::Error> + where + D: serde::Deserializer<'de>, + { + use serde::{self, Deserialize, de::Error}; + let s = String::deserialize(deserializer)?; + let dt = chrono::NaiveDateTime::parse_from_str(&s, $fmt).map_err(Error::custom)?; + Ok(<$type>::from_naive_utc_and_offset(dt, Utc)) + } + }; +} + +macro_rules! date_deserializer_local { + ($type:ty, $fmt:expr) => { + pub fn deserialize<'de, D>(deserializer: D) -> Result<$type, D::Error> + where + D: serde::Deserializer<'de>, + { + use serde::{self, Deserialize, de::Error}; + let s = String::deserialize(deserializer)?; + let dt = chrono::NaiveDateTime::parse_from_str(&s, $fmt).map_err(Error::custom)?; + Ok(dt) + } + }; +} + +macro_rules! date_deserializer_local_opt { + ($type:ty, $fmt:expr) => { + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: serde::Deserializer<'de>, + { + use serde::{self, Deserialize, de::Error}; + let Some(s) = Option::::deserialize(deserializer)? else { + return Ok(None); + }; + + Ok(Some( + chrono::NaiveDateTime::parse_from_str(&s, super::FORMAT_LOCAL) + .map_err(Error::custom)? + .and_local_timezone(Local) + .single() + .ok_or_else(|| Error::custom("Localtime conversion failed"))?, + )) + } + }; +} + +macro_rules! date_deserializer_utc_opt { + ($type:ty, $fmt:expr) => { + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: serde::Deserializer<'de>, + { + use serde::{self, Deserialize, de::Error}; + let Some(s) = Option::::deserialize(deserializer)? else { + return Ok(None); + }; + let dt = chrono::NaiveDateTime::parse_from_str(&s, $fmt).map_err(Error::custom)?; + Ok(Some(<$type>::from_naive_utc_and_offset(dt, Utc))) + } + }; +} + +pub mod utc_ms { + use chrono::{DateTime, Utc}; + + date_serializer!(DateTime, super::FORMAT_MS); + date_deserializer_utc!(DateTime, super::FORMAT_MS); +} + +pub mod update_utc { + use chrono::{DateTime, NaiveDateTime, Utc}; + use serde::{self, Deserialize, Deserializer, de::Error}; + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + let dt = NaiveDateTime::parse_from_str(&s, super::UPDATE_FORMAT).map_err(Error::custom)?; + Ok(DateTime::::from_naive_utc_and_offset(dt, Utc)) + } +} + +pub mod utc { + use chrono::{DateTime, Utc}; + + date_serializer!(DateTime, super::FORMAT); + date_deserializer_utc!(DateTime, super::FORMAT); +} + +pub mod utc_ms_opt { + use chrono::{DateTime, Utc}; + + date_serializer_opt!(DateTime, super::FORMAT_MS); + date_deserializer_utc_opt!(DateTime, super::FORMAT_MS); +} + +pub mod legacy_naive { + use chrono::NaiveDateTime; + + date_serializer!(NaiveDateTime, super::FORMAT_LOCAL); + date_deserializer_local!(NaiveDateTime, super::FORMAT_LOCAL); +} + +pub mod legacy_local_opt { + use chrono::{DateTime, Local}; + + date_serializer_opt!(DateTime, super::FORMAT_LOCAL); + date_deserializer_local_opt!(DateTime, super::FORMAT_LOCAL); +} + +pub mod legacy_utc { + use chrono::{DateTime, Utc}; + + date_serializer!(DateTime, super::FORMAT_LOCAL); + date_deserializer_utc!(DateTime, super::FORMAT_LOCAL); +} + +pub mod legacy_utc_opt { + use chrono::{DateTime, Utc}; + + date_serializer_opt!(DateTime, super::FORMAT_LOCAL); + date_deserializer_utc_opt!(DateTime, super::FORMAT_LOCAL); +} + +#[cfg_attr(coverage_nightly, coverage(off))] +#[cfg(test)] +mod tests { + use std::fmt::Debug; + + use chrono::{DateTime, Local, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc}; + use serde_json::de::StrRead; + + use crate::error::HueResult; + + fn de( + ds: &'static str, + d1: &T, + desi: impl Fn(&mut serde_json::Deserializer) -> serde_json::Result, + ) -> HueResult<()> { + let mut deser = serde_json::Deserializer::from_str(ds); + let d2 = desi(&mut deser)?; + + assert_eq!(*d1, d2); + Ok(()) + } + + fn se( + s1: &'static str, + seri: impl Fn(&mut serde_json::Serializer<&mut Vec>) -> serde_json::Result<()>, + ) -> HueResult<()> { + let mut s2 = vec![]; + let mut ser = serde_json::Serializer::new(&mut s2); + seri(&mut ser)?; + + eprintln!("{} vs {}", s1, s2.escape_ascii()); + assert_eq!(s1.as_bytes(), s2); + Ok(()) + } + + fn date_utc() -> (&'static str, DateTime) { + let dt = Utc.with_ymd_and_hms(2014, 7, 8, 9, 10, 11).unwrap(); + ("\"2014-07-08T09:10:11Z\"", dt) + } + + #[test] + fn utc_de() -> HueResult<()> { + let (ds, d1) = date_utc(); + de(ds, &d1, |de| super::utc::deserialize(de)) + } + + #[test] + fn utc_se() -> HueResult<()> { + let (s1, dt) = date_utc(); + se(s1, |ser| super::utc::serialize(&dt, ser)) + } + + fn date_utc_ms() -> (&'static str, DateTime) { + let dt = Utc.with_ymd_and_hms(2014, 7, 8, 9, 10, 11).unwrap(); + let dt = Utc + .timestamp_millis_opt(dt.timestamp_millis() + 123) + .unwrap(); + ("\"2014-07-08T09:10:11.123Z\"", dt) + } + + #[test] + fn utc_ms_de() -> HueResult<()> { + let (ds, d1) = date_utc_ms(); + de(ds, &d1, |de| super::utc_ms::deserialize(de)) + } + + #[test] + fn utc_ms_se() -> HueResult<()> { + let (s1, dt) = date_utc_ms(); + se(s1, |ser| super::utc_ms::serialize(&dt, ser)) + } + + #[test] + fn utc_ms_opt_de_some() -> HueResult<()> { + let (ds, d1) = date_utc_ms(); + de(ds, &Some(d1), |de| super::utc_ms_opt::deserialize(de)) + } + + #[test] + fn utc_ms_opt_de_none() -> HueResult<()> { + de("null", &None, |de| super::utc_ms_opt::deserialize(de)) + } + + #[test] + fn utc_ms_opt_se_some() -> HueResult<()> { + let (s1, dt) = date_utc_ms(); + se(s1, |ser| super::utc_ms_opt::serialize(&Some(dt), ser)) + } + + #[test] + fn utc_ms_opt_se_none() -> HueResult<()> { + se("null", |ser| super::utc_ms_opt::serialize(&None, ser)) + } + + fn date_legacy_naive() -> (&'static str, NaiveDateTime) { + let dt = NaiveDateTime::new( + NaiveDate::from_ymd_opt(2014, 7, 8).unwrap(), + NaiveTime::from_hms_opt(9, 10, 11).unwrap(), + ); + ("\"2014-07-08T09:10:11\"", dt) + } + + #[test] + fn legacy_naive_de() -> HueResult<()> { + let (ds, d1) = date_legacy_naive(); + de(ds, &d1, |de| super::legacy_naive::deserialize(de)) + } + + #[test] + fn legacy_naive_se() -> HueResult<()> { + let (s1, dt) = date_legacy_naive(); + se(s1, |ser| super::legacy_naive::serialize(&dt, ser)) + } + + fn date_legacy_local_opt() -> (&'static str, DateTime) { + let dt = Local.with_ymd_and_hms(2014, 7, 8, 9, 10, 11).unwrap(); + ("\"2014-07-08T09:10:11\"", dt) + } + + #[test] + fn legacy_local_opt_de_some() -> HueResult<()> { + let (ds, d1) = date_legacy_local_opt(); + de(ds, &Some(d1), |de| super::legacy_local_opt::deserialize(de)) + } + + #[test] + fn legacy_local_opt_se_some() -> HueResult<()> { + let (s1, dt) = date_legacy_local_opt(); + se(s1, |ser| super::legacy_local_opt::serialize(&Some(dt), ser)) + } + + #[test] + fn legacy_local_opt_de_none() -> HueResult<()> { + de("null", &None, |de| super::legacy_local_opt::deserialize(de)) + } + + #[test] + fn legacy_local_opt_se_none() -> HueResult<()> { + se("null", |ser| super::legacy_local_opt::serialize(&None, ser)) + } + + #[test] + fn update_utc_de() -> HueResult<()> { + let (ds, d1) = date_utc(); + de(ds, &d1, |de| super::update_utc::deserialize(de)) + } + + fn date_legacy_utc() -> (&'static str, DateTime) { + let dt = Utc.with_ymd_and_hms(2014, 7, 8, 9, 10, 11).unwrap(); + ("\"2014-07-08T09:10:11\"", dt) + } + + #[test] + fn legacy_utc_de() -> HueResult<()> { + let (ds, d1) = date_legacy_utc(); + de(ds, &d1, |de| super::legacy_utc::deserialize(de)) + } + + #[test] + fn legacy_utc_se() -> HueResult<()> { + let (s1, dt) = date_legacy_utc(); + se(s1, |ser| super::legacy_utc::serialize(&dt, ser)) + } +} diff --git a/crates/hue/src/devicedb.rs b/crates/hue/src/devicedb.rs new file mode 100644 index 0000000..ed32e27 --- /dev/null +++ b/crates/hue/src/devicedb.rs @@ -0,0 +1,124 @@ +use std::collections::BTreeMap; +use std::sync::LazyLock; + +use crate::api::{DeviceArchetype, DeviceProductData}; + +// This file contains discovered product data from multiple sources, +// including data samples from the community, and various open source or public +// domain examples, including: +// +// - https://github.com/niomwungeri-fabrice/hue-v2-api +// +// This file is a best-effort attempt to gather a database of product data, to +// provide more realistic API data, even when certain information is not +// available from the backend (zigbee2mqtt). + +#[derive(Debug, Clone)] +pub struct SimpleProductData<'a> { + pub manufacturer_name: &'a str, + pub product_name: &'a str, + pub product_archetype: DeviceArchetype, + pub hardware_platform_type: Option<&'a str>, +} + +impl<'a> SimpleProductData<'a> { + /// helper function to construct signify devices + #[must_use] + pub const fn signify( + product_name: &'a str, + product_archetype: DeviceArchetype, + hardware_platform_type: &'a str, + ) -> Self { + Self { + manufacturer_name: DeviceProductData::SIGNIFY_MANUFACTURER_NAME, + product_name, + product_archetype, + hardware_platform_type: Some(hardware_platform_type), + } + } +} + +static PRODUCT_DATA: LazyLock> = LazyLock::new(make_product_data); + +#[cfg_attr(coverage_nightly, coverage(off))] +fn make_product_data() -> BTreeMap<&'static str, SimpleProductData<'static>> { + // use shorter alias for better formatting + #[allow(clippy::enum_glob_use)] + use DeviceArchetype::*; + use SimpleProductData as SPD; + + maplit::btreemap! { + "915005987201" => SPD::signify("Signe gradient floor", HueSigne, "100b-118"), + "929003053301_01" => SPD::signify("Hue Ensis up", PendantLong, "100b-11f"), + "929003053301_02" => SPD::signify("Hue Ensis down", PendantLong, "100b-11f"), + "LCA001" => SPD::signify("Hue color lamp", SultanBulb, "100b-112"), + "LCD007" => SPD::signify("Hue color downlight", RecessedCeiling, "100b-114"), + "LCE002" => SPD::signify("Hue color candle", CandleBulb, "100b-114"), + "LCG002" => SPD::signify("Hue color spot", SpotBulb, "100b-114"), + "LCT014" => SPD::signify("Hue color lamp", SultanBulb, "100b-10c"), + "LCT015" => SPD::signify("Hue color lamp", SultanBulb, "100b-10c"), + "LCT016" => SPD::signify("Hue color lamp", SultanBulb, "100b-10c"), + "LCX001" => SPD::signify("Hue play gradient lightstrip", HueLightstripTv, "100b-118"), + "LCX005" => SPD::signify("Hue play gradient lightstrip", HueLightstripPc, "100b-118"), + "LLC020" => SPD::signify("Hue go", HueGo, "100b-108"), + "LOM001" => SPD::signify("Hue Smart plug", Plug, "100b-115"), + "LST002" => SPD::signify("Hue lightstrip plus", HueLightstrip, "100b-10f"), + "LTO001" => SPD::signify("Hue filament bulb", VintageBulb, "100b-114"), + "LTW015" => SPD::signify("Hue ambiance lamp", SultanBulb, "100b-10c"), + "LWA003" => SPD::signify("Hue white lamp", SultanBulb, "100b-114"), + "LWA029" => SPD::signify("Hue white lamp", SultanBulb, "100b-114"), + "LWB014" => SPD::signify("Hue white lamp", ClassicBulb, "100b-10c"), + "RDM002" => SPD::signify("Hue tap dial switch", UnknownArchetype, "100b-121"), + "RWL021" => SPD::signify("Hue dimmer switch", UnknownArchetype, "100b-109"), + "RWL022" => SPD::signify("Hue dimmer switch", UnknownArchetype, "100b-119"), + "SML001" => SPD::signify("Hue motion sensor", UnknownArchetype, "100b-10d"), + "SML002" => SPD::signify("Hue outdoor motion sensor", UnknownArchetype, "100b-10d"), + "SML003" => SPD::signify("Hue motion sensor", UnknownArchetype, "100b-11b"), + + "Z3-1BRL" => SPD { + manufacturer_name: "Lutron", + product_name: "Lutron Aurora", + product_archetype: UnknownArchetype, + hardware_platform_type: Some("1144-0"), + }, + } +} + +#[must_use] +pub fn product_data(model_id: &str) -> Option> { + PRODUCT_DATA.get(model_id).cloned() +} + +#[must_use] +pub fn product_archetype(model_id: &str) -> Option { + product_data(model_id).map(|pd| pd.product_archetype) +} + +#[must_use] +pub fn hardware_platform_type(model_id: &str) -> Option<&'static str> { + product_data(model_id).and_then(|pd| pd.hardware_platform_type) +} + +#[cfg(test)] +mod tests { + use crate::api::DeviceArchetype; + use crate::devicedb::{hardware_platform_type, product_archetype, product_data}; + + #[test] + fn lookup_spf() { + assert!(product_data("LCX001").is_some()); + } + + #[test] + fn lookup_archetype() { + assert_eq!( + product_archetype("LCX001").unwrap(), + DeviceArchetype::HueLightstripTv + ); + } + + #[test] + fn lookup_platform_type() { + assert_eq!(hardware_platform_type("LCX001").unwrap(), "100b-118",); + } +} diff --git a/crates/hue/src/diff.rs b/crates/hue/src/diff.rs new file mode 100644 index 0000000..2f50772 --- /dev/null +++ b/crates/hue/src/diff.rs @@ -0,0 +1,242 @@ +use std::collections::BTreeSet; + +use serde::{Serialize, de::DeserializeOwned}; +use serde_json::{Map, Value}; + +use crate::error::{HueError, HueResult}; + +// These properties, if present, are always included, even when unchanged +const WHITELIST_KEYS: &[&str] = &["owner", "service_id"]; + +pub fn event_update_diff(ma: Value, mb: Value) -> HueResult> { + let (Value::Object(mut a), Value::Object(mut b)) = (ma, mb) else { + return Err(HueError::Undiffable); + }; + + let mut diff = Map::new(); + + // did we add any meaningful differences? + let mut changed = false; + + // First, remove any whitelisted keys from both maps, + // and prefer version from "b" value + for key in WHITELIST_KEYS { + let va = a.remove(*key); + let vb = b.remove(*key); + + changed |= va != vb; + + if let Some(value) = vb.or(va) { + diff.insert((*key).to_string(), value); + } + } + + let ka = a.keys().cloned().collect::>(); + let kb = b.keys().cloned().collect::>(); + + // Keys that have appeared will be included + for key in &kb - &ka { + diff.insert(key.clone(), b.remove(&key).unwrap()); + changed = true; + } + + // Keys that are common will be included, if changed + for key in &ka & &kb { + if a[&key] != b[&key] { + diff.insert(key.clone(), b.remove(&key).unwrap()); + changed = true; + } + } + + if !changed { + return Ok(None); + } + + Ok(Some(Value::Object(diff))) +} + +pub fn event_update_apply(ma: &T, mb: Value) -> HueResult { + let ma = serde_json::to_value(ma)?; + + let (Value::Object(mut a), Value::Object(b)) = (ma, mb) else { + return Err(HueError::Unmergable); + }; + + for (key, value) in b { + a.insert(key, value); + } + + Ok(serde_json::from_value(Value::Object(a))?) +} + +#[cfg_attr(coverage_nightly, coverage(off))] +#[cfg(test)] +mod tests { + use serde_json::Value; + use serde_json::json; + + use crate::diff::event_update_apply as apply; + use crate::diff::event_update_diff as diff; + use crate::error::HueError; + + #[test] + fn diff_empty() { + let a = json!({}); + let b = json!({}); + + assert_eq!(diff(a, b).unwrap(), None); + } + + #[test] + fn diff_invalid() { + let a = json!([]); + let b = json!({}); + + assert!(matches!(diff(a, b).unwrap_err(), HueError::Undiffable)); + } + + #[test] + fn diff_value_unchanged() { + let a = json!({"x": 42}); + let b = json!({"x": 42}); + + assert_eq!(diff(a, b).unwrap(), None); + } + + #[test] + fn diff_whitelist_unchanged() { + let a = json!({"owner": 42}); + let b = json!({"owner": 42}); + + assert_eq!(diff(a, b).unwrap(), None); + } + + #[test] + fn diff_value_removed() { + let a = json!({"x": 42}); + let b = json!({}); + + assert_eq!(diff(a, b).unwrap(), None); + } + + #[test] + fn diff_value_added() { + let a = json!({}); + let b = json!({"x": 42}); + let c = json!({"x": 42}); + + assert_eq!(diff(a, b).unwrap(), Some(c)); + } + + #[test] + fn diff_value_changed() { + let a = json!({"x": 17}); + let b = json!({"x": 42}); + let c = json!({"x": 42}); + + assert_eq!(diff(a, b).unwrap(), Some(c)); + } + + #[test] + fn diff_whitelist_removed() { + let a = json!({"owner": 17}); + let b = json!({}); + let c = json!({"owner": 17}); + + assert_eq!(diff(a, b).unwrap(), Some(c)); + } + + #[test] + fn diff_whitelist_added() { + let a = json!({}); + let b = json!({"owner": 17}); + let c = json!({"owner": 17}); + + assert_eq!(diff(a, b).unwrap(), Some(c)); + } + + #[test] + fn diff_whitelist_changed() { + let a = json!({"owner": 17}); + let b = json!({"owner": 42}); + let c = json!({"owner": 42}); + + assert_eq!(diff(a, b).unwrap(), Some(c)); + } + + #[test] + fn diff_value_type_changed() { + let a = json!({"x": 17}); + let b = json!({"x": "foo"}); + let c = json!({"x": "foo"}); + + assert_eq!(diff(a, b).unwrap(), Some(c)); + } + + #[test] + fn diff_whitelist_type_changed() { + let a = json!({"owner": 17}); + let b = json!({"owner": "foo"}); + let c = json!({"owner": "foo"}); + + assert_eq!(diff(a, b).unwrap(), Some(c)); + } + + #[test] + fn apply_empty() { + let a = json!({}); + let b = json!({}); + let c = json!({}); + + assert_eq!(apply(&a, b).unwrap(), c); + } + + #[test] + fn apply_invalid() { + let a = json!({}); + let b = json!([]); + + assert!(matches!(apply(&a, b).unwrap_err(), HueError::Unmergable)); + + let a = json!([]); + let b = json!({}); + + assert!(matches!(apply(&a, b).unwrap_err(), HueError::Unmergable)); + } + + #[test] + fn apply_simply() { + let a = json!({}); + let b = json!({"x": "y"}); + let c = json!({"x": "y"}); + + assert_eq!(apply(&a, b).unwrap(), c); + } + + #[test] + fn apply_overwrite() { + let a = json!({"x": "before"}); + let b = json!({"x": "after"}); + let c = json!({"x": "after"}); + + assert_eq!(apply(&a, b).unwrap(), c); + } + + #[test] + fn apply_null() { + let a = json!({"x": "before"}); + let b = json!({"x": Value::Null}); + let c = json!({"x": Value::Null}); + + assert_eq!(apply(&a, b).unwrap(), c); + } + + #[test] + fn apply_some() { + let a = json!({"x": "unchanged"}); + let b = json!({"x": "unchanged", "y": "new"}); + let c = json!({"x": "unchanged", "y": "new"}); + + assert_eq!(apply(&a, b).unwrap(), c); + } +} diff --git a/crates/hue/src/effect_duration.rs b/crates/hue/src/effect_duration.rs new file mode 100644 index 0000000..59145ca --- /dev/null +++ b/crates/hue/src/effect_duration.rs @@ -0,0 +1,350 @@ +use crate::error::{HueError, HueResult}; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct EffectDuration(pub u8); + +impl EffectDuration { + const RESOLUTION_01S: u32 = 1; // 1s. + const RESOLUTION_05S: u32 = 5; // 5s. + const RESOLUTION_15S: u32 = 15; // 15s. + const RESOLUTION_01M: u32 = 60; // 1min. + const RESOLUTION_05M: u32 = 5 * 60; // 5min. + + pub const fn from_ms(milliseconds: u32) -> HueResult { + let rounded_seconds = (milliseconds + 500) / 1000; + Self::from_seconds(rounded_seconds) + } + + #[allow(clippy::cast_possible_truncation)] + #[allow(clippy::cast_sign_loss)] + pub const fn from_seconds(seconds: u32) -> HueResult { + let (base, resolution) = match seconds { + 0..1 => return Ok(Self(251)), + 1..60 => (252, Self::RESOLUTION_01S), + 60..293 => (204, Self::RESOLUTION_05S), + 293..295 => { + return Ok(Self(146)); + } + 295..878 => (165, Self::RESOLUTION_15S), + 878..885 => { + return Ok(Self(107)); + } + 885..3510 => (121, Self::RESOLUTION_01M), + 3510..3540 => { + return Ok(Self(63)); + } + 3540..=21600 => (74, Self::RESOLUTION_05M), + _ => { + return Err(HueError::EffectDurationOutOfRange(seconds)); + } + }; + Ok(Self( + base - ((seconds + (resolution / 2)) / resolution) as u8, + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + pub fn seconds_to_effect_duration() { + // sniffed from the real Hue hub + let values = vec![ + (5, 145), + (10, 125), + (15, 106), + (20, 101), + (25, 96), + (30, 91), + (35, 86), + (40, 81), + (45, 76), + (50, 71), + (55, 66), + (60, 62), + ]; + for (input, output) in values { + assert_eq!( + EffectDuration::from_seconds(input * 60).unwrap(), + EffectDuration(output) + ); + } + } + + #[allow(clippy::unreadable_literal)] + const DURATION_BREAKPOINTS: &[(u32, u8)] = &[ + (1499, 251), + (2499, 250), + (3499, 249), + (4499, 248), + (5499, 247), + (6499, 246), + (7499, 245), + (8499, 244), + (9499, 243), + (10499, 242), + (11499, 241), + (12499, 240), + (13499, 239), + (14499, 238), + (15499, 237), + (16499, 236), + (17499, 235), + (18499, 234), + (19499, 233), + (20499, 232), + (21499, 231), + (22499, 230), + (23499, 229), + (24499, 228), + (25499, 227), + (26499, 226), + (27499, 225), + (28499, 224), + (29499, 223), + (30499, 222), + (31499, 221), + (32499, 220), + (33499, 219), + (34499, 218), + (35499, 217), + (36499, 216), + (37499, 215), + (38499, 214), + (39499, 213), + (40499, 212), + (41499, 211), + (42499, 210), + (43499, 209), + (44499, 208), + (45499, 207), + (46499, 206), + (47499, 205), + (48499, 204), + (49499, 203), + (50499, 202), + (51499, 201), + (52499, 200), + (53499, 199), + (54499, 198), + (55499, 197), + (56499, 196), + (57499, 195), + (58499, 194), + (59499, 193), + (62499, 192), + (67499, 191), + (72499, 190), + (77499, 189), + (82499, 188), + (87499, 187), + (92499, 186), + (97499, 185), + (102499, 184), + (107499, 183), + (112499, 182), + (117499, 181), + (122499, 180), + (127499, 179), + (132499, 178), + (137499, 177), + (142499, 176), + (147499, 175), + (152499, 174), + (157499, 173), + (162499, 172), + (167499, 171), + (172499, 170), + (177499, 169), + (182499, 168), + (187499, 167), + (192499, 166), + (197499, 165), + (202499, 164), + (207499, 163), + (212499, 162), + (217499, 161), + (222499, 160), + (227499, 159), + (232499, 158), + (237499, 157), + (242499, 156), + (247499, 155), + (252499, 154), + (257499, 153), + (262499, 152), + (267499, 151), + (272499, 150), + (277499, 149), + (282499, 148), + (287499, 147), + (294499, 146), + (307499, 145), + (322499, 144), + (337499, 143), + (352499, 142), + (367499, 141), + (382499, 140), + (397499, 139), + (412499, 138), + (427499, 137), + (442499, 136), + (457499, 135), + (472499, 134), + (487499, 133), + (502499, 132), + (517499, 131), + (532499, 130), + (547499, 129), + (562499, 128), + (577499, 127), + (592499, 126), + (607499, 125), + (622499, 124), + (637499, 123), + (652499, 122), + (667499, 121), + (682499, 120), + (697499, 119), + (712499, 118), + (727499, 117), + (742499, 116), + (757499, 115), + (772499, 114), + (787499, 113), + (802499, 112), + (817499, 111), + (832499, 110), + (847499, 109), + (862499, 108), + (884499, 107), + (929499, 106), + (989499, 105), + (1049499, 104), + (1109499, 103), + (1169499, 102), + (1229499, 101), + (1289499, 100), + (1349499, 99), + (1409499, 98), + (1469499, 97), + (1529499, 96), + (1589499, 95), + (1649499, 94), + (1709499, 93), + (1769499, 92), + (1829499, 91), + (1889499, 90), + (1949499, 89), + (2009499, 88), + (2069499, 87), + (2129499, 86), + (2189499, 85), + (2249499, 84), + (2309499, 83), + (2369499, 82), + (2429499, 81), + (2489499, 80), + (2549499, 79), + (2609499, 78), + (2669499, 77), + (2729499, 76), + (2789499, 75), + (2849499, 74), + (2909499, 73), + (2969499, 72), + (3029499, 71), + (3089499, 70), + (3149499, 69), + (3209499, 68), + (3269499, 67), + (3329499, 66), + (3389499, 65), + (3449499, 64), + (3539499, 63), + (3749499, 62), + (4049499, 61), + (4349499, 60), + (4649499, 59), + (4949499, 58), + (5249499, 57), + (5549499, 56), + (5849499, 55), + (6149499, 54), + (6449499, 53), + (6749499, 52), + (7049499, 51), + (7349499, 50), + (7649499, 49), + (7949499, 48), + (8249499, 47), + (8549499, 46), + (8849499, 45), + (9149499, 44), + (9449499, 43), + (9749499, 42), + (10049499, 41), + (10349499, 40), + (10649499, 39), + (10949499, 38), + (11249499, 37), + (11549499, 36), + (11849499, 35), + (12149499, 34), + (12449499, 33), + (12749499, 32), + (13049499, 31), + (13349499, 30), + (13649499, 29), + (13949499, 28), + (14249499, 27), + (14549499, 26), + (14849499, 25), + (15149499, 24), + (15449499, 23), + (15749499, 22), + (16049499, 21), + (16349499, 20), + (16649499, 19), + (16949499, 18), + (17249499, 17), + (17549499, 16), + (17849499, 15), + (18149499, 14), + (18449499, 13), + (18749499, 12), + (19049499, 11), + (19349499, 10), + (19649499, 9), + (19949499, 8), + (20249499, 7), + (20549499, 6), + (20849499, 5), + (21149499, 4), + (21449499, 3), + (21600000, 2), + ]; + + #[test] + pub fn complete_conformance_test() { + let mut c = 1; + for (x, y) in DURATION_BREAKPOINTS { + while c <= *x { + assert_eq!( + EffectDuration::from_ms(c).unwrap().0, + *y, + "failed for {c}ms" + ); + c += 1; + } + } + } + + #[test] + pub fn out_of_range() { + let seconds = 10 * 60 * 60; // 10h + assert!(EffectDuration::from_seconds(seconds).is_err()); + } +} diff --git a/crates/hue/src/error.rs b/crates/hue/src/error.rs new file mode 100644 index 0000000..5ef491f --- /dev/null +++ b/crates/hue/src/error.rs @@ -0,0 +1,119 @@ +use thiserror::Error; + +use crate::api::RType; + +#[derive(Error, Debug)] +pub enum HueError { + /* mapped errors */ + #[error(transparent)] + FromUtf8Error(#[from] std::string::FromUtf8Error), + + #[error(transparent)] + IOError(#[from] std::io::Error), + + #[error(transparent)] + SerdeJson(#[from] serde_json::Error), + + #[error(transparent)] + TryFromIntError(#[from] std::num::TryFromIntError), + + #[error(transparent)] + FromHexError(#[from] hex::FromHexError), + + #[error(transparent)] + PackedStructError(#[from] packed_struct::PackingError), + + #[error(transparent)] + UuidError(#[from] uuid::Error), + + #[error("Bad header in hue entertainment stream")] + HueEntertainmentBadHeader, + + #[error("Failed to decode Hue Zigbee Update")] + HueZigbeeDecodeError, + + #[error("Failed to encode Hue Zigbee Update")] + HueZigbeeEncodeError, + + #[error("Failed to decode Hue Zigbee Update: Unknown flags {0:04x}")] + HueZigbeeUnknownFlags(u16), + + #[error("Resource {0} not found")] + NotFound(uuid::Uuid), + + #[error("Resource {0} not found")] + V1NotFound(u32), + + #[error("Cannot allocate any more {0:?}")] + Full(RType), + + #[error("Resource type wrong: expected {0:?} but found {1:?}")] + WrongType(RType, RType), + + #[error("Cannot generate json difference between non-map objects")] + Undiffable, + + #[error("Cannot merge json difference between non-map object")] + Unmergable, + + #[error("Effect duration out of range: {0}")] + EffectDurationOutOfRange(u32), +} + +/// Error types for Hue Bridge v1 API +#[derive(Error, Debug, Clone, Copy)] +pub enum HueApiV1Error { + /// Type 1 + #[error("Unauthorized")] + UnauthorizedUser = 1, + + /// Type 2 + #[error("Body contains invalid JSON")] + BodyContainsInvalidJson = 2, + + /// Type 3 + #[error("Resource not found")] + ResourceNotfound = 3, + + /// Type 4 + #[error("Method not available for resource")] + MethodNotAvailableForResource = 4, + + /// Type 5 + #[error("Missing parameters in body")] + MissingParametersInBody = 5, + + /// Type 6 + #[error("Parameter not available")] + ParameterNotAvailable = 6, + + /// Type 7 + #[error("Invalid value for parameter")] + InvalidValueForParameter = 7, + + /// Type 8 + #[error("Parameter not modifiable")] + ParameterNotModifiable = 8, + + /// Type 11 + #[error("Too many items in list")] + TooManyItemsInList = 11, + + /// Type 12 + #[error("Portal connection is required")] + PortalConnectionIsRequired = 12, + + /// Type 901 + #[error("Internal bridge error")] + BridgeInternalError = 901, +} + +impl HueApiV1Error { + #[cfg_attr(coverage_nightly, coverage(off))] + #[must_use] + pub const fn error_code(&self) -> u32 { + *self as u32 + } +} + +pub type HueResult = Result; diff --git a/crates/hue/src/event.rs b/crates/hue/src/event.rs new file mode 100644 index 0000000..88931f2 --- /dev/null +++ b/crates/hue/src/event.rs @@ -0,0 +1,179 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +use crate::api::{RType, ResourceRecord}; +use crate::date_format; + +#[cfg(feature = "rng")] +use crate::api::ResourceLink; +#[cfg(feature = "rng")] +use crate::error::HueResult; + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "lowercase", tag = "type")] +pub enum Event { + Add(Add), + Update(Update), + Delete(Delete), + Error(Error), +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct EventBlock { + #[serde(with = "date_format::utc")] + pub creationtime: DateTime, + pub id: Uuid, + #[serde(flatten)] + pub event: Event, +} + +#[cfg(feature = "rng")] +impl EventBlock { + #[must_use] + pub fn add(data: Vec) -> Self { + Self { + creationtime: Utc::now(), + id: Uuid::new_v4(), + event: Event::Add(Add { data }), + } + } + + pub fn update(id: &Uuid, id_v1: Option, rtype: RType, data: Value) -> HueResult { + Ok(Self { + creationtime: Utc::now(), + id: Uuid::new_v4(), + event: Event::Update(Update { + data: vec![ObjectUpdate { + id: *id, + id_v1, + rtype, + data, + }], + }), + }) + } + + pub fn delete(link: ResourceLink, id_v1: Option) -> HueResult { + Ok(Self { + creationtime: Utc::now(), + id: Uuid::new_v4(), + event: Event::Delete(Delete { + data: vec![ObjectDelete { + id: link.rid, + rtype: link.rtype, + id_v1, + }], + }), + }) + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Add { + pub data: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ObjectUpdate { + pub id: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub id_v1: Option, + #[serde(rename = "type")] + pub rtype: RType, + #[serde(flatten)] + pub data: Value, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Update { + pub data: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ObjectDelete { + pub id: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub id_v1: Option, + #[serde(rename = "type")] + pub rtype: RType, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Delete { + pub data: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Error {} + +#[cfg_attr(coverage_nightly, coverage(off))] +#[cfg(test)] +mod tests { + use serde_json::json; + use uuid::Uuid; + + use crate::api::{RType, Resource, ResourceLink, ResourceRecord}; + use crate::event::{Add, Delete, Event, EventBlock, Update}; + + // just some uuid for testing + const ID: Uuid = Uuid::NAMESPACE_DNS; + + #[test] + fn add() { + let obj = ResourceRecord::new( + ID, + None, + Resource::AuthV1(ResourceLink { + rid: ID, + rtype: RType::AuthV1, + }), + ); + + let add = EventBlock::add(vec![obj.clone()]); + let Event::Add(Add { data }) = add.event else { + panic!("Wrong event type"); + }; + + assert!(data.len() == 1); + + assert_eq!( + serde_json::to_string(&data[0]).unwrap(), + serde_json::to_string(&obj).unwrap() + ); + } + + #[test] + fn update() { + let diff = json!({"key": "value"}); + + let evt = EventBlock::update(&ID, Some("foo".into()), RType::AuthV1, diff.clone()).unwrap(); + let Event::Update(Update { data }) = evt.event else { + panic!("Wrong event type"); + }; + + assert!(data.len() == 1); + + let out = &data[0]; + assert_eq!(out.id_v1, Some("foo".into())); + assert_eq!(out.rtype, RType::AuthV1); + assert_eq!(out.data, diff); + } + + #[test] + fn delete() { + let evt = EventBlock::delete(RType::AuthV1.link_to(ID), Some("foo".into())).unwrap(); + + let Event::Delete(Delete { data }) = evt.event else { + panic!("Wrong event type"); + }; + + assert!(data.len() == 1); + + let out = &data[0]; + assert_eq!(out.id_v1, Some("foo".into())); + assert_eq!(out.rtype, RType::AuthV1); + assert_eq!(out.id, ID); + } +} diff --git a/crates/hue/src/flags.rs b/crates/hue/src/flags.rs new file mode 100644 index 0000000..907fa58 --- /dev/null +++ b/crates/hue/src/flags.rs @@ -0,0 +1,46 @@ +pub trait TakeFlag { + fn take(&mut self, flag: Self) -> bool; +} + +impl TakeFlag for T { + fn take(&mut self, flag: Self) -> bool { + let found = self.contains(flag); + if found { + self.remove(flag); + } + found + } +} + +#[cfg(test)] +mod tests { + use bitflags::bitflags; + + use crate::flags::TakeFlag; + + bitflags! { + #[derive(Debug, Clone, Copy)] + pub struct Flags: u16 { + const BIT = 1; + } + } + + #[test] + fn take_none() { + let mut fl = Flags::from_bits(0).unwrap(); + assert!(!fl.take(Flags::BIT)); + } + + #[test] + fn take_one() { + let mut fl = Flags::from_bits(1).unwrap(); + assert!(fl.take(Flags::BIT)); + } + + #[test] + fn take_twice() { + let mut fl = Flags::from_bits(1).unwrap(); + assert!(fl.take(Flags::BIT)); + assert!(!fl.take(Flags::BIT)); + } +} diff --git a/crates/hue/src/gamma.rs b/crates/hue/src/gamma.rs new file mode 100644 index 0000000..c2c0078 --- /dev/null +++ b/crates/hue/src/gamma.rs @@ -0,0 +1,121 @@ +// This module is heavily inspired by MIT-licensed code found here: +// +// https://viereck.ch/hue-xy-rgb/ +// +// Original code by Thomas Lochmatter + +pub struct GammaCorrection { + gamma: f64, + transition: f64, + slope: f64, + offset: f64, +} + +impl GammaCorrection { + #[must_use] + pub const fn new(gamma: f64, transition: f64, slope: f64, offset: f64) -> Self { + Self { + gamma, + transition, + slope, + offset, + } + } + + #[must_use] + pub fn transform(&self, value: f64) -> f64 { + if value <= self.transition { + self.slope * value + } else { + (1.0 + self.offset).mul_add(value.powf(self.gamma), -self.offset) + } + } + + #[must_use] + pub fn inverse(&self, value: f64) -> f64 { + if value <= self.transform(self.transition) { + value / self.slope + } else { + ((value + self.offset) / (1.0 + self.offset)).powf(1.0 / self.gamma) + } + } +} + +impl Default for GammaCorrection { + fn default() -> Self { + Self::NONE + } +} + +impl GammaCorrection { + /// Identity mapping ("f(x) -> x"), i.e. no gamma correction + pub const NONE: Self = Self { + gamma: 1.0, + transition: 0.0, + slope: 1.0, + offset: 0.0, + }; + + /// Standard gamma correction for sRGB color space + pub const SRGB: Self = Self::new(0.42, 0.003_130_8, 12.92, 0.055); +} + +#[cfg(test)] +mod tests { + use crate::gamma::GammaCorrection; + use crate::{compare, compare_float}; + + #[test] + fn gamma_new() { + let gc = GammaCorrection::new(1.0, 2.0, 3.0, 4.0); + + compare!(gc.gamma, 1.0); + compare!(gc.transition, 2.0); + compare!(gc.slope, 3.0); + compare!(gc.offset, 4.0); + } + + #[test] + fn gamma_default() { + let gc = GammaCorrection::default(); + let none = GammaCorrection::NONE; + + compare!(gc.gamma, none.gamma); + compare!(gc.transition, none.transition); + compare!(gc.slope, none.slope); + compare!(gc.offset, none.offset); + } + + #[test] + fn gamma_none() { + let gc = GammaCorrection::NONE; + + compare!(gc.transform(0.0), 0.0); + compare!(gc.transform(0.1), 0.1); + compare!(gc.transform(0.9), 0.9); + compare!(gc.transform(1.0), 1.0); + compare!(gc.transform(10.0), 10.0); + } + + #[test] + fn inv_gamma_none() { + let gc = GammaCorrection::NONE; + + compare!(gc.inverse(0.0), 0.0); + compare!(gc.inverse(0.1), 0.1); + compare!(gc.inverse(0.9), 0.9); + compare!(gc.inverse(1.0), 1.0); + compare!(gc.inverse(10.0), 10.0); + } + + #[test] + fn srgb_roundtrip() { + let gc = GammaCorrection::SRGB; + + compare!(gc.inverse(gc.transform(0.0)), 0.0); + compare!(gc.inverse(gc.transform(0.1)), 0.1); + compare!(gc.inverse(gc.transform(0.9)), 0.9); + compare!(gc.inverse(gc.transform(1.0)), 1.0); + compare!(gc.inverse(gc.transform(10.0)), 10.0); + } +} diff --git a/crates/hue/src/hs.rs b/crates/hue/src/hs.rs new file mode 100644 index 0000000..b1d98cf --- /dev/null +++ b/crates/hue/src/hs.rs @@ -0,0 +1,58 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Copy, Debug, Serialize, Deserialize, Clone)] +pub struct HS { + pub hue: f64, + pub sat: f64, +} + +#[derive(Copy, Debug, Serialize, Deserialize, Clone, Eq, PartialEq)] +pub struct RawHS { + pub hue: u16, + pub sat: u8, +} + +impl From for HS { + fn from(raw: RawHS) -> Self { + Self { + hue: f64::from(raw.hue) / f64::from(0xFFFF), + sat: f64::from(raw.sat) / f64::from(0xFF), + } + } +} + +#[cfg(test)] +mod tests { + use crate::hs::{HS, RawHS}; + use crate::{compare, compare_float, compare_hs}; + + #[test] + fn from_rawhs_min() { + compare_hs!( + HS::from(RawHS { hue: 0, sat: 0 }), + HS { hue: 0.0, sat: 0.0 } + ); + } + + #[test] + fn from_rawhs_mid() { + compare_hs!( + HS::from(RawHS { + hue: 0xCCCC, + sat: 0xCC + }), + HS { hue: 0.8, sat: 0.8 } + ); + } + + #[test] + fn from_rawhs_max() { + compare_hs!( + HS::from(RawHS { + hue: 0xFFFF, + sat: 0xFF + }), + HS { hue: 1.0, sat: 1.0 } + ); + } +} diff --git a/crates/hue/src/legacy_api.rs b/crates/hue/src/legacy_api.rs new file mode 100644 index 0000000..4a89254 --- /dev/null +++ b/crates/hue/src/legacy_api.rs @@ -0,0 +1,930 @@ +use std::{collections::HashMap, net::Ipv4Addr}; + +use chrono::{DateTime, Local, NaiveDateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use uuid::Uuid; + +use crate::api::{ColorGamut, DeviceProductData}; +use crate::date_format; +use crate::hs::RawHS; +use crate::{api, best_guess_timezone}; + +#[cfg(feature = "mac")] +use crate::version::SwVersion; +#[cfg(feature = "mac")] +use mac_address::MacAddress; + +#[derive(Debug, Serialize, Deserialize)] +pub struct HueError { + #[serde(rename = "type")] + typ: u32, + address: String, + description: String, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum HueApiResult { + Success(T), + Error(HueError), +} + +#[cfg(feature = "mac")] +pub fn serialize_lower_case_mac(mac: &MacAddress, serializer: S) -> Result +where + S: serde::Serializer, +{ + let m = mac.bytes(); + let addr = format!( + "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", + m[0], m[1], m[2], m[3], m[4], m[5] + ); + serializer.serialize_str(&addr) +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ApiShortConfig { + pub apiversion: String, + pub bridgeid: String, + pub datastoreversion: String, + pub factorynew: bool, + #[cfg(feature = "mac")] + #[serde(serialize_with = "serialize_lower_case_mac")] + pub mac: MacAddress, + #[cfg(not(feature = "mac"))] + pub mac: String, + pub modelid: String, + pub name: String, + pub replacesbridgeid: Option, + pub starterkitid: String, + pub swversion: String, +} + +impl Default for ApiShortConfig { + #[allow(clippy::default_trait_access)] + fn default() -> Self { + Self { + apiversion: crate::HUE_BRIDGE_V2_DEFAULT_APIVERSION.to_string(), + bridgeid: "0000000000000000".to_string(), + datastoreversion: "176".to_string(), + factorynew: false, + mac: Default::default(), + modelid: crate::HUE_BRIDGE_V2_MODEL_ID.to_string(), + name: "Bifrost Bridge".to_string(), + replacesbridgeid: None, + starterkitid: String::new(), + swversion: crate::HUE_BRIDGE_V2_DEFAULT_SWVERSION.to_string(), + } + } +} + +#[cfg(feature = "mac")] +impl ApiShortConfig { + #[must_use] + pub fn from_mac_and_version(mac: MacAddress, version: &SwVersion) -> Self { + Self { + bridgeid: crate::bridge_id(mac).to_uppercase(), + apiversion: version.get_legacy_apiversion(), + swversion: version.get_legacy_swversion(), + mac, + ..Self::default() + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ApiResourceType { + Config, + Groups, + Lights, + Resourcelinks, + Rules, + Scenes, + Schedules, + Sensors, + Capabilities, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct NewUser { + pub devicetype: String, + #[serde(default)] + pub generateclientkey: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct NewUserReply { + pub username: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub clientkey: Option, +} + +#[allow(non_camel_case_types)] +#[derive(Debug, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum ConnectionState { + Connected, + #[default] + Disconnected, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ApiInternetServices { + pub internet: ConnectionState, + pub remoteaccess: ConnectionState, + pub swupdate: ConnectionState, + pub time: ConnectionState, +} + +impl Default for ApiInternetServices { + fn default() -> Self { + Self { + internet: ConnectionState::Connected, + remoteaccess: ConnectionState::Connected, + swupdate: ConnectionState::Connected, + time: ConnectionState::Connected, + } + } +} + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct PortalState { + communication: ConnectionState, + incoming: bool, + outgoing: bool, + signedon: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ApiBackup { + pub errorcode: u32, + pub status: String, +} + +impl Default for ApiBackup { + fn default() -> Self { + Self { + errorcode: 0, + status: "idle".to_string(), + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SwUpdate { + #[serde(with = "date_format::legacy_utc")] + lastinstall: DateTime, + state: SwUpdateState, +} + +impl Default for SwUpdate { + fn default() -> Self { + Self { + lastinstall: Utc::now(), + state: SwUpdateState::NoUpdates, + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SwUpdateState { + NoUpdates, + Transferring, + ReadyToInstall, + AnyReadyToInstall, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SoftwareUpdate2 { + autoinstall: Value, + bridge: SwUpdate, + checkforupdate: bool, + #[serde(with = "date_format::legacy_utc")] + lastchange: DateTime, + state: SwUpdateState, +} + +impl SoftwareUpdate2 { + #[allow(clippy::new_without_default)] + #[must_use] + pub fn new() -> Self { + Self { + autoinstall: json!({ "on": true, "updatetime": "T14:00:00" }), + bridge: SwUpdate { + lastinstall: Utc::now(), + state: SwUpdateState::NoUpdates, + }, + checkforupdate: false, + lastchange: Utc::now(), + state: SwUpdateState::NoUpdates, + } + } +} + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Whitelist { + #[serde(with = "date_format::legacy_utc", rename = "create date")] + pub create_date: DateTime, + #[serde(with = "date_format::legacy_utc", rename = "last use date")] + pub last_use_date: DateTime, + pub name: String, +} + +#[allow(clippy::struct_excessive_bools)] +#[derive(Debug, Serialize, Deserialize)] +pub struct ApiConfig { + pub analyticsconsent: bool, + pub backup: ApiBackup, + #[serde(flatten)] + pub short_config: ApiShortConfig, + pub dhcp: bool, + pub internetservices: ApiInternetServices, + pub linkbutton: bool, + pub portalconnection: ConnectionState, + pub portalservices: bool, + pub portalstate: PortalState, + pub proxyaddress: String, + pub proxyport: u16, + pub swupdate2: SoftwareUpdate2, + pub zigbeechannel: u8, + pub ipaddress: Ipv4Addr, + pub netmask: Ipv4Addr, + pub gateway: Ipv4Addr, + pub timezone: String, + #[serde(with = "date_format::legacy_utc", rename = "UTC")] + pub utc: DateTime, + #[serde(with = "date_format::legacy_naive")] + pub localtime: NaiveDateTime, + pub whitelist: HashMap, +} + +#[derive(Debug, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum ApiEffect { + #[default] + None, +} + +#[derive(Debug, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum ApiAlert { + #[default] + None, + Select, +} + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct ApiGroupAction { + pub on: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub bri: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub hue: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sat: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub effect: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub xy: Option<[f64; 2]>, + #[serde(skip_serializing_if = "Option::is_none")] + pub ct: Option, + pub alert: ApiAlert, + #[serde(skip_serializing_if = "Option::is_none")] + pub colormode: Option, +} + +#[derive(Debug, Serialize, Deserialize, Default, PartialEq, Eq)] +pub enum ApiGroupType { + Entertainment, + #[default] + LightGroup, + Room, + Zone, +} + +#[derive(Debug, Serialize, Deserialize, Default, PartialEq, Eq)] +pub enum ApiGroupClass { + #[serde(rename = "Living room")] + LivingRoom, + Kitchen, + Dining, + Bedroom, + #[serde(rename = "Kids bedroom")] + KidsBedroom, + Bathroom, + Nursery, + Recreation, + Office, + Gym, + Hallway, + Toilet, + #[serde(rename = "Front door")] + FrontDoor, + Garage, + Terrace, + Garden, + Driveway, + Carport, + #[default] + Other, + + Home, + Downstairs, + Upstairs, + #[serde(rename = "Top floor")] + TopFloor, + Attic, + #[serde(rename = "Guest room")] + GuestRoom, + Staircase, + Lounge, + #[serde(rename = "Man cave")] + ManCave, + Computer, + Studio, + Music, + TV, + Reading, + Closet, + Storage, + #[serde(rename = "Laundry room")] + LaundryRoom, + Balcony, + Porch, + Barbecue, + Pool, + Free, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ApiGroup { + pub name: String, + pub lights: Vec, + pub action: ApiGroupAction, + + #[serde(rename = "type")] + pub group_type: ApiGroupType, + pub class: ApiGroupClass, + pub recycle: bool, + pub sensors: Vec, + pub state: ApiGroupState, + #[serde(skip_serializing_if = "Value::is_null", default)] + pub stream: Value, + #[serde(skip_serializing_if = "Value::is_null", default)] + pub locations: Value, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ApiGroupNew { + pub name: Option, + #[serde(default, rename = "type")] + pub group_type: ApiGroupType, + #[serde(default)] + pub class: ApiGroupClass, + pub lights: Vec, +} + +impl ApiGroup { + #[must_use] + pub fn make_group_0() -> Self { + Self { + name: "Group 0".into(), + lights: vec![], + action: ApiGroupAction::default(), + group_type: ApiGroupType::LightGroup, + class: ApiGroupClass::default(), + recycle: false, + sensors: vec![], + state: ApiGroupState::default(), + stream: Value::Null, + locations: Value::Null, + } + } + + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + #[must_use] + pub fn from_lights_and_room( + glight: &api::GroupedLight, + lights: Vec, + room: api::Room, + ) -> Self { + Self { + name: room.metadata.name, + lights, + action: ApiGroupAction { + on: glight.on.is_some_and(|on| on.on), + bri: glight.dimming.map(|dim| (dim.brightness * 2.54) as u32), + hue: None, + sat: None, + effect: None, + xy: None, + ct: None, + alert: ApiAlert::None, + colormode: None, + }, + class: ApiGroupClass::default(), + group_type: ApiGroupType::Room, + recycle: false, + sensors: vec![], + state: ApiGroupState::default(), + stream: Value::Null, + locations: Value::Null, + } + } +} + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct ApiGroupState { + pub all_on: bool, + pub any_on: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum LightColorMode { + Ct, + Xy, + Hs, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ApiLightState { + on: bool, + #[serde(skip_serializing_if = "Option::is_none")] + bri: Option, + #[serde(skip_serializing_if = "Option::is_none")] + hue: Option, + #[serde(skip_serializing_if = "Option::is_none")] + sat: Option, + #[serde(skip_serializing_if = "Option::is_none")] + effect: Option, + #[serde(skip_serializing_if = "Option::is_none")] + xy: Option<[f64; 2]>, + #[serde(skip_serializing_if = "Option::is_none")] + ct: Option, + alert: String, + #[serde(skip_serializing_if = "Option::is_none")] + colormode: Option, + mode: String, + reachable: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ApiLightStateUpdate { + #[serde(skip_serializing_if = "Option::is_none")] + pub on: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bri: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub xy: Option<[f64; 2]>, + #[serde(skip_serializing_if = "Option::is_none")] + pub ct: Option, + #[serde(skip_serializing_if = "Option::is_none", flatten)] + pub hs: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub transitiontime: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ApiGroupUpdate { + pub scene: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Copy)] +pub struct Active { + pub active: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ApiGroupUpdate2 { + pub lights: Option>, + pub name: Option, + pub stream: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ApiGroupActionUpdate { + GroupUpdate(ApiGroupUpdate), + LightUpdate(ApiLightStateUpdate), +} + +impl From for ApiLightStateUpdate { + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + fn from(action: api::SceneAction) -> Self { + Self { + on: action.on.map(|on| on.on), + bri: action.dimming.map(|dim| (dim.brightness * 2.54) as u8), + xy: action.color.map(|col| col.xy.into()), + ct: action.color_temperature.and_then(|ct| ct.mirek), + hs: None, + transitiontime: None, + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ApiLight { + state: ApiLightState, + swupdate: SwUpdate, + #[serde(rename = "type")] + light_type: String, + name: String, + modelid: String, + manufacturername: String, + productname: String, + capabilities: Value, + config: Value, + uniqueid: String, + swversion: String, + #[serde(skip_serializing_if = "Option::is_none")] + swconfigid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + productid: Option, +} + +impl ApiLight { + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + #[must_use] + pub fn from_dev_and_light(uuid: &Uuid, dev: &api::Device, light: &api::Light) -> Self { + let colormode = if light.color.is_some() { + LightColorMode::Xy + } else { + LightColorMode::Ct + }; + + let product_data = dev.product_data.clone(); + + Self { + state: ApiLightState { + on: light.on.on, + bri: light + .dimming + .map(|dim| ((dim.brightness * 2.54) as u32).max(1)), + hue: None, + sat: None, + effect: Some("none".into()), + xy: light.color.clone().map(|col| col.xy.into()), + ct: light.color_temperature.clone().and_then(|ct| ct.mirek), + alert: "select".into(), + colormode: Some(colormode), + mode: "homeautomation".to_string(), + reachable: true, + }, + swupdate: SwUpdate::default(), + name: light.metadata.name.clone(), + modelid: product_data.model_id, + manufacturername: product_data.manufacturer_name, + productname: product_data.product_name, + productid: product_data.hardware_platform_type, + + capabilities: json!({ + "certified": true, + "control": { + "colorgamut": [ + [ColorGamut::GAMUT_C.red.x, ColorGamut::GAMUT_C.red.y ], + [ColorGamut::GAMUT_C.green.x, ColorGamut::GAMUT_C.green.y], + [ColorGamut::GAMUT_C.blue.x, ColorGamut::GAMUT_C.blue.y ], + ], + "colorgamuttype": "C", + "ct": { + "max": 500, + "min": 153 + }, + "maxlumen": 800, + "mindimlevel": 10 + }, + "streaming": { + "proxy": true, + "renderer": true + } + }), + config: json!({ + "archetype": "spotbulb", + "function": "mixed", + "direction": "downwards", + "startup": { + "mode": "safety", + "configured": true + } + }), + light_type: "Extended color light".to_string(), + + /* FIXME: Should have form "00:11:22:33:44:55:66:77-0b" */ + uniqueid: uuid.as_simple().to_string(), + + swversion: product_data.software_version, + + /* FIXME: Should have form "9012C6FD" */ + swconfigid: None, + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ApiResourceLink { + #[serde(rename = "type")] + pub link_type: String, + pub name: String, + pub description: String, + pub classid: u32, + pub owner: Uuid, + pub recycle: bool, + pub links: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ApiRule { + pub name: String, + pub recycle: bool, + pub status: String, + pub conditions: Vec, + pub actions: Vec, + pub owner: Uuid, + pub timestriggered: u32, + #[serde(with = "date_format::legacy_utc")] + pub created: DateTime, + pub lasttriggered: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub enum ApiSceneType { + LightScene, + GroupScene, +} + +#[derive(Debug, Serialize, Deserialize)] +pub enum ApiSceneVersion { + V2 = 2, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ApiSceneAppData { + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ApiScene { + pub name: String, + #[serde(rename = "type")] + pub scene_type: ApiSceneType, + pub lights: Vec, + #[serde(skip_serializing_if = "HashMap::is_empty", default)] + pub lightstates: HashMap, + pub owner: String, + pub recycle: bool, + pub locked: bool, + pub appdata: ApiSceneAppData, + pub picture: String, + #[serde(with = "date_format::legacy_utc")] + pub lastupdated: DateTime, + pub version: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub image: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub group: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ApiSchedule { + pub recycle: bool, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub autodelete: Option, + pub description: String, + pub command: Value, + #[serde(with = "date_format::legacy_utc")] + pub created: DateTime, + #[serde( + with = "date_format::legacy_utc_opt", + default, + skip_serializing_if = "Option::is_none" + )] + pub starttime: Option>, + pub time: String, + pub localtime: String, + pub status: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ApiSensor { + #[serde(rename = "type")] + pub sensor_type: String, + pub config: Value, + pub name: String, + pub state: Value, + pub manufacturername: String, + pub modelid: String, + pub swversion: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub swupdate: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub uniqueid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub diversityid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub productname: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub recycle: Option, + #[serde(skip_serializing_if = "Value::is_null", default)] + pub capabilities: Value, +} + +impl ApiSensor { + #[must_use] + pub fn builtin_daylight_sensor() -> Self { + Self { + config: json!({ + "configured": false, + "on": true, + "sunriseoffset": 30, + "sunsetoffset": -30 + }), + manufacturername: DeviceProductData::SIGNIFY_MANUFACTURER_NAME.to_string(), + modelid: "PHDL00".to_string(), + name: "Daylight".to_string(), + state: json!({ + "daylight": Value::Null, + "lastupdated": "none", + }), + swversion: "1.0".to_string(), + sensor_type: "Daylight".to_string(), + swupdate: None, + uniqueid: None, + diversityid: None, + productname: None, + recycle: None, + capabilities: Value::Null, + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ApiUserConfig { + pub config: ApiConfig, + pub groups: HashMap, + pub lights: HashMap, + pub resourcelinks: HashMap, + pub rules: HashMap, + pub scenes: HashMap, + pub schedules: HashMap, + pub sensors: HashMap, +} + +impl Default for ApiConfig { + fn default() -> Self { + Self { + analyticsconsent: false, + backup: ApiBackup::default(), + short_config: ApiShortConfig::default(), + dhcp: true, + internetservices: ApiInternetServices::default(), + linkbutton: Default::default(), + portalconnection: ConnectionState::Disconnected, + portalservices: true, + portalstate: PortalState::default(), + proxyaddress: "none".to_string(), + proxyport: Default::default(), + swupdate2: SoftwareUpdate2::new(), + zigbeechannel: 25, + ipaddress: Ipv4Addr::UNSPECIFIED, + netmask: Ipv4Addr::UNSPECIFIED, + gateway: Ipv4Addr::UNSPECIFIED, + timezone: best_guess_timezone(), + utc: Utc::now(), + localtime: Local::now().naive_local(), + whitelist: HashMap::new(), + } + } +} + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Capacity { + pub available: u32, + pub total: u32, +} + +impl Capacity { + #[must_use] + pub const fn new(total: u32, available: u32) -> Self { + Self { available, total } + } +} + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct SensorsCapacity { + pub available: u32, + pub total: u32, + pub clip: Capacity, + pub zll: Capacity, + pub zgp: Capacity, +} + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct ScenesCapacity { + pub available: u32, + pub total: u32, + pub lightstates: Capacity, +} + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct RulesCapacity { + pub available: u32, + pub total: u32, + pub conditions: Capacity, + pub actions: Capacity, +} + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct SceneCapacity { + #[serde(flatten)] + pub scenes: Capacity, + pub lightstates: Capacity, +} + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct StreamingCapacity { + pub available: u32, + pub total: u32, + pub channels: u32, +} + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct Capabilities { + pub lights: Capacity, + pub sensors: SensorsCapacity, + pub groups: Capacity, + pub scenes: SceneCapacity, + pub schedules: Capacity, + pub rules: RulesCapacity, + pub resourcelinks: Capacity, + pub streaming: StreamingCapacity, + pub timezones: Value, +} + +impl Capabilities { + #[must_use] + pub fn new() -> Self { + Self { + lights: Capacity::new(63, 62), + sensors: SensorsCapacity { + available: 249, + total: 250, + clip: Capacity::new(250, 249), + zll: Capacity::new(64, 64), + zgp: Capacity::new(64, 64), + }, + groups: Capacity::new(64, 60), + scenes: SceneCapacity { + scenes: Capacity::new(200, 175), + lightstates: Capacity::new(12600, 11025), + }, + schedules: Capacity::new(100, 100), + rules: RulesCapacity { + available: 250, + total: 250, + conditions: Capacity::new(1500, 1500), + actions: Capacity::new(1000, 1000), + }, + resourcelinks: Capacity::new(64, 64), + streaming: StreamingCapacity { + available: 1, + total: 1, + channels: 20, + }, + timezones: json!({ + "values": [ + "CET", + "UTC", + "GMT", + "Europe/Copenhagen", + ], + }), + } + } +} + +#[cfg(test)] +mod tests { + #[cfg(feature = "mac")] + #[test] + fn serialize_lower_case_mac() { + use mac_address::MacAddress; + + use crate::legacy_api::serialize_lower_case_mac; + + let mac = MacAddress::new([0x01, 0x02, 0x03, 0xAA, 0xBB, 0xCC]); + let mut res = vec![]; + let mut ser = serde_json::Serializer::new(&mut res); + + serialize_lower_case_mac(&mac, &mut ser).unwrap(); + + assert_eq!(res, b"\"01:02:03:aa:bb:cc\""); + } +} diff --git a/crates/hue/src/lib.rs b/crates/hue/src/lib.rs new file mode 100644 index 0000000..c089776 --- /dev/null +++ b/crates/hue/src/lib.rs @@ -0,0 +1,167 @@ +#![doc = include_str!("../../../doc/hue-zigbee-format.md")] +#![cfg_attr(coverage_nightly, feature(coverage_attribute))] + +pub mod api; +pub mod clamp; +pub mod colorspace; +pub mod colortemp; +pub mod date_format; +pub mod devicedb; +pub mod diff; +pub mod effect_duration; +pub mod error; +pub mod flags; +pub mod gamma; +pub mod hs; +pub mod legacy_api; +pub mod scene_icons; +pub mod stream; +pub mod update; +pub mod version; +pub mod xy; +pub mod zigbee; + +#[cfg(feature = "event")] +pub mod event; + +#[cfg(feature = "mac")] +use mac_address::MacAddress; + +pub const WIDE_GAMUT_MAX_X: f64 = 0.7347; +pub const WIDE_GAMUT_MAX_Y: f64 = 0.8264; + +pub const HUE_BRIDGE_V2_MODEL_ID: &str = "BSB002"; +pub const HUE_BRIDGE_V2_DEFAULT_SWVERSION: u64 = 1_970_084_010; +pub const HUE_BRIDGE_V2_DEFAULT_APIVERSION: &str = "1.70.0"; + +#[must_use] +pub fn best_guess_timezone() -> String { + iana_time_zone::get_timezone().unwrap_or_else(|_| "none".to_string()) +} + +#[cfg(feature = "mac")] +#[must_use] +pub fn bridge_id_raw(mac: MacAddress) -> [u8; 8] { + let b = mac.bytes(); + [b[0], b[1], b[2], 0xFF, 0xFE, b[3], b[4], b[5]] +} + +#[cfg(feature = "mac")] +#[must_use] +pub fn bridge_id(mac: MacAddress) -> String { + hex::encode(bridge_id_raw(mac)) +} + +#[cfg(test)] +mod tests { + use mac_address::MacAddress; + + use crate::version::SwVersion; + use crate::{HUE_BRIDGE_V2_DEFAULT_APIVERSION, HUE_BRIDGE_V2_DEFAULT_SWVERSION}; + + #[macro_export] + macro_rules! compare_float { + ($expr:expr, $value:expr, $diff:expr) => { + let a = $expr; + let b = $value; + eprintln!("{a} vs {b:.4} (diff {})", $diff); + assert!((a - b).abs() < $diff); + }; + } + + #[macro_export] + macro_rules! compare { + ($expr:expr, $value:expr) => { + compare_float!($expr, $value, 1e-4) + }; + } + + #[macro_export] + macro_rules! compare_hs { + ($a:expr, $b:expr) => {{ + compare!($a.hue, $b.hue); + compare!($a.sat, $b.sat); + }}; + } + + #[macro_export] + macro_rules! compare_xy { + ($expr:expr, $value:expr) => { + let a = $expr; + let b = $value; + compare!(a.x, b.x); + compare!(a.y, b.y); + }; + } + + #[macro_export] + macro_rules! compare_xy_quant { + ($expr:expr, $value:expr) => { + let a = $expr; + let b = $value; + compare_float!(a.x, b.x, 1e-3); + compare_float!(a.y, b.y, 1e-3); + }; + } + + #[macro_export] + macro_rules! compare_rgb { + ($a:expr, $b:expr) => {{ + eprintln!("Comparing r"); + compare!($a[0], $b[0]); + eprintln!("Comparing g"); + compare!($a[1], $b[1]); + eprintln!("Comparing b"); + compare!($a[2], $b[2]); + }}; + } + + #[macro_export] + macro_rules! compare_matrix { + ($a:expr, $b:expr) => { + zip($a, $b).for_each(|(a, b)| { + compare!(a, b); + }); + }; + } + + #[macro_export] + macro_rules! compare_hsl_rgb { + ($h:expr, $s:expr, $rgb:expr) => {{ + let sat = $s; + compare_rgb!(XY::rgb_from_hsl(HS { hue: $h, sat }, 0.5), $rgb); + }}; + } + + /// verify that `HUE_BRIDGE_V2_DEFAULT_SWVERSION` and + /// `HUE_BRIDGE_V2_DEFAULT_APIVERSION` are synchronized + #[test] + fn default_version_match() { + let ver = SwVersion::new(HUE_BRIDGE_V2_DEFAULT_SWVERSION, String::new()); + assert_eq!( + HUE_BRIDGE_V2_DEFAULT_APIVERSION, + ver.get_legacy_apiversion() + ); + } + + #[test] + fn best_guess_timezone() { + let res = crate::best_guess_timezone(); + assert!(!res.is_empty()); + assert_ne!(res, "none"); + } + + #[test] + fn bridge_id() { + let mac = MacAddress::new([0x11, 0x22, 0x33, 0x44, 0x55, 0x66]); + let id = crate::bridge_id(mac); + assert_eq!(id, "112233fffe445566"); + } + + #[test] + fn bridge_id_raw() { + let mac = MacAddress::new([0x11, 0x22, 0x33, 0x44, 0x55, 0x66]); + let id = crate::bridge_id_raw(mac); + assert_eq!(id, [0x11, 0x22, 0x33, 0xFF, 0xFE, 0x44, 0x55, 0x66]); + } +} diff --git a/crates/hue/src/scene_icons.rs b/crates/hue/src/scene_icons.rs new file mode 100644 index 0000000..6e76f0b --- /dev/null +++ b/crates/hue/src/scene_icons.rs @@ -0,0 +1,14 @@ +/* rustfmt wants to make the formatting worse.. */ +#![cfg_attr(rustfmt, rustfmt_skip)] + +use uuid::{uuid, Uuid}; + +pub const RELAX: Uuid = uuid!("a1f7da49-d181-4328-abea-68c9dc4b5416"); +pub const NIGHT_LIGHT: Uuid = uuid!("28bbfeff-1a0c-444e-bb4b-0b74b88e0c95"); +pub const DIMMED: Uuid = uuid!("8c74b9ba-6e89-4083-a2a7-b10a1e566fed"); +pub const ENERGIZE: Uuid = uuid!("7fd2ccc5-5749-4142-b7a5-66405a676f03"); +pub const READ: Uuid = uuid!("e101a77f-9984-4f61-aac8-15741983c656"); +pub const COOL_BRIGHT: Uuid = uuid!("dbccef2b-096e-49df-93c2-726665e80b26"); +pub const BRIGHT: Uuid = uuid!("732ff1d9-76a7-4630-aad0-c8acc499bb0b"); +pub const REST: Uuid = uuid!("11a09ad5-8d65-4e90-959b-f05981a9ab1b"); +pub const CONCENTRATE: Uuid = uuid!("b90c8900-a6b7-422c-a5d3-e170187dbf8c"); diff --git a/crates/hue/src/stream.rs b/crates/hue/src/stream.rs new file mode 100644 index 0000000..2fb58c4 --- /dev/null +++ b/crates/hue/src/stream.rs @@ -0,0 +1,483 @@ +use packed_struct::prelude::*; +use packed_struct::types::bits::ByteArray; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::{HueError, HueResult}; +use crate::xy::XY; + +#[derive(PrimitiveEnum_u8, Clone, Debug, Copy, PartialEq, Eq)] +pub enum HueStreamColorMode { + Rgb = 0x00, + Xy = 0x01, +} + +#[derive(PrimitiveEnum_u8, Clone, Debug, Copy, PartialEq, Eq)] +pub enum HueStreamVersion { + V1 = 0x01, + V2 = 0x02, +} + +#[derive(PackedStruct, Clone, Debug)] +#[packed_struct(size = "16", endian = "msb")] +pub struct HueStreamHeader { + magic: [u8; 9], + #[packed_field(ty = "enum", size_bytes = "1")] + version: HueStreamVersion, + x0: u8, + seqnr: u8, + x1: u16, + #[packed_field(size_bytes = "1", ty = "enum")] + color_mode: HueStreamColorMode, + x2: u8, +} + +impl HueStreamHeader { + pub const MAGIC: &[u8] = b"HueStream"; + pub const SIZE: usize = size_of::<::ByteArray>(); + + pub fn parse(data: &[u8]) -> HueResult { + if data.len() < Self::SIZE { + return Err(HueError::HueEntertainmentBadHeader); + } + + let hdr = Self::unpack_from_slice(&data[..Self::SIZE])?; + + if hdr.magic != Self::MAGIC { + return Err(HueError::HueEntertainmentBadHeader); + } + + Ok(hdr) + } +} + +#[derive(Clone, Debug)] +pub enum HueStreamPacket { + V1(HueStreamPacketV1), + V2(HueStreamPacketV2), +} + +#[derive(Clone, Debug)] +pub struct HueStreamPacketV1 { + pub lights: HueStreamLightsV1, +} + +impl HueStreamPacketV1 { + #[must_use] + pub const fn color_mode(&self) -> HueStreamColorMode { + match self.lights { + HueStreamLightsV1::Rgb(_) => HueStreamColorMode::Rgb, + HueStreamLightsV1::Xy(_) => HueStreamColorMode::Xy, + } + } + + #[must_use] + pub fn light_ids(&self) -> Vec { + match &self.lights { + HueStreamLightsV1::Rgb(rgb) => rgb.iter().map(|light| light.light_id).collect(), + HueStreamLightsV1::Xy(xy) => xy.iter().map(|light| light.light_id).collect(), + } + } +} + +impl HueStreamPacketV2 { + #[must_use] + pub const fn color_mode(&self) -> HueStreamColorMode { + match self.lights { + HueStreamLightsV2::Rgb(_) => HueStreamColorMode::Rgb, + HueStreamLightsV2::Xy(_) => HueStreamColorMode::Xy, + } + } +} + +#[derive(Clone, Debug)] +pub struct HueStreamPacketV2 { + pub area: Uuid, + pub lights: HueStreamLightsV2, +} + +impl HueStreamPacket { + /// Size of uuid in printed ("dashed") form + const ASCII_UUID_SIZE: usize = 36; + + pub fn parse(data: &[u8]) -> HueResult { + let hdr = HueStreamHeader::parse(data)?; + let body = &data[HueStreamHeader::SIZE..]; + match hdr.version { + HueStreamVersion::V1 => { + let lights = HueStreamLightsV1::parse(hdr.color_mode, body)?; + Ok(Self::V1(HueStreamPacketV1 { lights })) + } + HueStreamVersion::V2 => { + if body.len() < Self::ASCII_UUID_SIZE { + return Err(HueError::HueEntertainmentBadHeader); + } + let (area_bytes, body) = body.split_at(Self::ASCII_UUID_SIZE); + let area = Uuid::try_parse_ascii(area_bytes)?; + let lights = HueStreamLightsV2::parse(hdr.color_mode, body)?; + Ok(Self::V2(HueStreamPacketV2 { area, lights })) + } + } + } + + #[must_use] + pub const fn color_mode(&self) -> HueStreamColorMode { + match self { + Self::V1(v1) => v1.color_mode(), + Self::V2(v2) => v2.color_mode(), + } + } +} + +#[derive(PackedStruct, Clone, Debug, Copy, Serialize, Deserialize)] +#[packed_struct(size = "9", endian = "msb")] +pub struct Rgb16V1 { + #[packed_field(size_bytes = "3")] + pub light_id: u32, + #[packed_field(size_bytes = "6")] + pub rgb: Rgb16, +} + +#[derive(PackedStruct, Clone, Debug, Copy, Serialize, Deserialize)] +#[packed_struct(size = "9", endian = "msb")] +pub struct Xy16V1 { + #[packed_field(size_bytes = "3")] + pub light_id: u32, + #[packed_field(size_bytes = "6")] + pub xy: Xy16, +} + +#[derive(PackedStruct, Clone, Debug, Copy, Serialize, Deserialize)] +#[packed_struct(size = "7", endian = "msb")] +pub struct Rgb16V2 { + pub channel: u8, + #[packed_field(size_bytes = "6")] + pub rgb: Rgb16, +} + +#[derive(PackedStruct, Clone, Debug, Copy, Serialize, Deserialize)] +#[packed_struct(size = "7", endian = "msb")] +pub struct Xy16V2 { + pub channel: u8, + #[packed_field(size_bytes = "6")] + pub xy: Xy16, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum HueStreamLightsV1 { + Rgb(Vec), + Xy(Vec), +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum HueStreamLightsV2 { + Rgb(Vec), + Xy(Vec), +} + +fn parse_list(data: &[u8]) -> HueResult> { + let res = data + .chunks_exact(T::ByteArray::len()) + .map(T::unpack_from_slice) + .collect::>()?; + + Ok(res) +} + +impl HueStreamLightsV1 { + pub fn parse(color_mode: HueStreamColorMode, data: &[u8]) -> HueResult { + match color_mode { + HueStreamColorMode::Rgb => Ok(Self::Rgb(parse_list(data)?)), + HueStreamColorMode::Xy => Ok(Self::Xy(parse_list(data)?)), + } + } +} + +impl HueStreamLightsV2 { + pub fn parse(color_mode: HueStreamColorMode, data: &[u8]) -> HueResult { + match color_mode { + HueStreamColorMode::Rgb => Ok(Self::Rgb(parse_list(data)?)), + HueStreamColorMode::Xy => Ok(Self::Xy(parse_list(data)?)), + } + } +} + +#[derive(PackedStruct, Clone, Debug, Copy, Serialize, Deserialize)] +#[packed_struct(size = "6", endian = "msb")] +pub struct Rgb16 { + pub r: u16, + pub g: u16, + pub b: u16, +} + +impl Rgb16 { + #[must_use] + pub fn to_xy(&self) -> (XY, f64) { + XY::from_rgb( + (self.r / 256) as u8, + (self.g / 256) as u8, + (self.b / 256) as u8, + ) + } +} + +#[derive(PackedStruct, Clone, Debug, Copy, Serialize, Deserialize)] +#[packed_struct(size = "6", endian = "msb")] +pub struct Xy16 { + pub x: u16, + pub y: u16, + pub b: u16, +} + +impl Xy16 { + #[must_use] + pub fn to_xy(&self) -> (XY, f64) { + ( + XY::new( + f64::from(self.x) / f64::from(0xFFFF), + f64::from(self.y) / f64::from(0xFFFF), + ), + f64::from(self.b) / f64::from(0x101), + ) + } +} + +#[cfg_attr(coverage_nightly, coverage(off))] +#[cfg(test)] +mod tests { + use crate::error::HueError; + use crate::stream::{ + HueStreamColorMode, HueStreamHeader, HueStreamLightsV1, HueStreamLightsV2, HueStreamPacket, + Rgb16, Xy16, + }; + use crate::xy::XY; + use crate::{compare, compare_float, compare_xy}; + + #[test] + fn rgb16_to_xy() { + let rgb16 = Rgb16 { + r: 0xFFFF, + g: 0xFFFF, + b: 0xFFFF, + }; + + let (xy, b) = rgb16.to_xy(); + + compare_xy!(xy, XY::D50_WHITE_POINT); + compare_float!(b, 255.0, 1e-2); + } + + #[test] + fn xy16_to_xy() { + let xy16 = Xy16 { + x: 0x8000, + y: 0xFFFF, + b: 0xFFFF, + }; + + let (xy, b) = xy16.to_xy(); + + compare!(xy.x, 0.5); + compare!(xy.y, 1.0); + compare!(b, 255.0); + } + + #[test] + fn parse_stream_lights_v1_rgb() { + let data = [0x11, 0x22, 0x33, 0xA0, 0xA1, 0xB0, 0xB1, 0xC0, 0xC1]; + let raw = HueStreamLightsV1::parse(HueStreamColorMode::Rgb, &data).unwrap(); + let res = match raw { + HueStreamLightsV1::Rgb(rgb) => rgb, + HueStreamLightsV1::Xy(_) => panic!(), + }; + + assert_eq!(res.len(), 1); + assert_eq!(res[0].light_id, 0x11_22_33); + assert_eq!(res[0].rgb.r, 0xA0A1); + assert_eq!(res[0].rgb.g, 0xB0B1); + assert_eq!(res[0].rgb.b, 0xC0C1); + } + + #[test] + fn parse_stream_lights_v1_xy() { + let data = [0x11, 0x22, 0x33, 0xA0, 0xA1, 0xB0, 0xB1, 0xC0, 0xC1]; + let raw = HueStreamLightsV1::parse(HueStreamColorMode::Xy, &data).unwrap(); + let res = match raw { + HueStreamLightsV1::Rgb(_) => panic!(), + HueStreamLightsV1::Xy(xy) => xy, + }; + + assert_eq!(res.len(), 1); + assert_eq!(res[0].light_id, 0x11_22_33); + assert_eq!(res[0].xy.x, 0xA0A1); + assert_eq!(res[0].xy.y, 0xB0B1); + assert_eq!(res[0].xy.b, 0xC0C1); + } + + #[test] + fn parse_stream_lights_v2_rgb() { + let data = [0x11, 0xA0, 0xA1, 0xB0, 0xB1, 0xC0, 0xC1]; + let raw = HueStreamLightsV2::parse(HueStreamColorMode::Rgb, &data).unwrap(); + let res = match raw { + HueStreamLightsV2::Rgb(rgb) => rgb, + HueStreamLightsV2::Xy(_) => panic!(), + }; + + assert_eq!(res.len(), 1); + assert_eq!(res[0].channel, 0x11); + assert_eq!(res[0].rgb.r, 0xA0A1); + assert_eq!(res[0].rgb.g, 0xB0B1); + assert_eq!(res[0].rgb.b, 0xC0C1); + } + + #[test] + fn parse_stream_lights_v2_xy() { + let data = [0x11, 0xA0, 0xA1, 0xB0, 0xB1, 0xC0, 0xC1]; + let raw = HueStreamLightsV2::parse(HueStreamColorMode::Xy, &data).unwrap(); + let res = match raw { + HueStreamLightsV2::Rgb(_) => panic!(), + HueStreamLightsV2::Xy(xy) => xy, + }; + + assert_eq!(res.len(), 1); + assert_eq!(res[0].channel, 0x11); + assert_eq!(res[0].xy.x, 0xA0A1); + assert_eq!(res[0].xy.y, 0xB0B1); + assert_eq!(res[0].xy.b, 0xC0C1); + } + + #[test] + fn parse_packet_bad_size() { + let data = vec![0x00, 0x01]; + + let err = HueStreamPacket::parse(&data).unwrap_err(); + assert!(matches!(err, HueError::HueEntertainmentBadHeader)); + } + + #[test] + fn parse_packet_bad_header() { + let mut data = HueStreamHeader::MAGIC.to_vec(); + data.extend_from_slice(&[ + 0x01, // version + 0x00, // x0 + 0x00, // seqnr + 0x00, 0x00, // x1 + 0x00, // color_mode: rgb + 0x00, // x2, + ]); + + // corrupt first byte + data[0] = b'X'; + + let err = HueStreamPacket::parse(&data).unwrap_err(); + assert!(matches!(err, HueError::HueEntertainmentBadHeader)); + } + + #[test] + fn parse_packet_v1_rgb() { + let mut data = HueStreamHeader::MAGIC.to_vec(); + data.extend_from_slice(&[ + 0x01, // version + 0x00, // x0 + 0x00, // seqnr + 0x00, 0x00, // x1 + 0x00, // color_mode: rgb + 0x00, // x2, + ]); + data.extend_from_slice(&[0x11, 0x22, 0x33, 0xA0, 0xA1, 0xB0, 0xB1, 0xC0, 0xC1]); + + let res = HueStreamPacket::parse(&data).unwrap(); + + assert_eq!(res.color_mode(), HueStreamColorMode::Rgb); + + match res { + HueStreamPacket::V1(v1) => { + assert_eq!(v1.light_ids(), [0x11_22_33]); + } + HueStreamPacket::V2(_) => panic!(), + } + } + + #[test] + fn parse_packet_v1_xy() { + let mut data = HueStreamHeader::MAGIC.to_vec(); + data.extend_from_slice(&[ + 0x01, // version + 0x00, // x0 + 0x00, // seqnr + 0x00, 0x00, // x1 + 0x01, // color_mode: xy + 0x00, // x2, + ]); + data.extend_from_slice(&[0x11, 0x22, 0x33, 0xA0, 0xA1, 0xB0, 0xB1, 0xC0, 0xC1]); + + let res = HueStreamPacket::parse(&data).unwrap(); + + assert_eq!(res.color_mode(), HueStreamColorMode::Xy); + + match res { + HueStreamPacket::V1(v1) => { + assert_eq!(v1.light_ids(), [0x11_22_33]); + } + HueStreamPacket::V2(_) => panic!(), + } + } + + #[test] + fn parse_packet_v2_missing_uuid() { + let mut data = HueStreamHeader::MAGIC.to_vec(); + data.extend_from_slice(&[ + 0x02, // version + 0x00, // x0 + 0x00, // seqnr + 0x00, 0x00, // x1 + 0x00, // color_mode: rgb + 0x00, // x2, + ]); + + // dummy data + data.push(0x00); + + let err = HueStreamPacket::parse(&data).unwrap_err(); + + assert!(matches!(err, HueError::HueEntertainmentBadHeader)); + } + + #[test] + fn parse_packet_v2_rgb() { + let mut data = HueStreamHeader::MAGIC.to_vec(); + data.extend_from_slice(&[ + 0x02, // version + 0x00, // x0 + 0x00, // seqnr + 0x00, 0x00, // x1 + 0x00, // color_mode: rgb + 0x00, // x2, + ]); + data.extend_from_slice(b"01010101-0202-0303-0404-050505050505"); + data.extend_from_slice(&[0x11, 0xA0, 0xA1, 0xB0, 0xB1, 0xC0, 0xC1]); + + let res = HueStreamPacket::parse(&data).unwrap(); + + assert_eq!(res.color_mode(), HueStreamColorMode::Rgb); + } + + #[test] + fn parse_packet_v2_xy() { + let mut data = HueStreamHeader::MAGIC.to_vec(); + data.extend_from_slice(&[ + 0x02, // version + 0x00, // x0 + 0x00, // seqnr + 0x00, 0x00, // x1 + 0x01, // color_mode: xy + 0x00, // x2, + ]); + data.extend_from_slice(b"01010101-0202-0303-0404-050505050505"); + data.extend_from_slice(&[0x11, 0xA0, 0xA1, 0xB0, 0xB1, 0xC0, 0xC1]); + + let res = HueStreamPacket::parse(&data).unwrap(); + + assert_eq!(res.color_mode(), HueStreamColorMode::Xy); + } +} diff --git a/crates/hue/src/update.rs b/crates/hue/src/update.rs new file mode 100644 index 0000000..d192017 --- /dev/null +++ b/crates/hue/src/update.rs @@ -0,0 +1,45 @@ +use chrono::{DateTime, Utc}; +use serde::Deserialize; + +use crate::date_format; + +// Full request goes to {UPDATE_CHECK_URL}?deviceTypeId=BSB002&version=1 +pub const UPDATE_CHECK_URL: &str = "https://firmware.meethue.com/v1/checkupdate"; + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateEntry { + #[serde(with = "date_format::update_utc")] + pub created_at: DateTime, + #[serde(with = "date_format::update_utc")] + pub updated_at: DateTime, + pub file_size: u64, + pub md5: String, + pub binary_url: String, + pub version: u64, + pub version_name: String, + pub release_notes: String, +} + +#[derive(Deserialize)] +pub struct UpdateEntries { + pub updates: Vec, +} + +#[must_use] +pub fn update_url_for_bridge(device_type_id: &str, version: u64) -> String { + format!("{UPDATE_CHECK_URL}?deviceTypeId={device_type_id}&version={version}") +} + +#[cfg(test)] +mod tests { + use crate::update::{UPDATE_CHECK_URL, update_url_for_bridge}; + + #[test] + fn url() { + assert_eq!( + update_url_for_bridge("dev", 1234), + format!("{UPDATE_CHECK_URL}?deviceTypeId=dev&version=1234") + ); + } +} diff --git a/crates/hue/src/version.rs b/crates/hue/src/version.rs new file mode 100644 index 0000000..86f86a8 --- /dev/null +++ b/crates/hue/src/version.rs @@ -0,0 +1,148 @@ +use std::fmt::Debug; + +use crate::{HUE_BRIDGE_V2_DEFAULT_APIVERSION, HUE_BRIDGE_V2_DEFAULT_SWVERSION}; + +#[derive(Clone, Eq, PartialEq)] +pub struct SwVersion { + version: u64, + name: String, +} + +impl PartialOrd for SwVersion { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for SwVersion { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.version.cmp(&other.version) + } +} + +impl Default for SwVersion { + fn default() -> Self { + Self { + version: HUE_BRIDGE_V2_DEFAULT_SWVERSION, + name: HUE_BRIDGE_V2_DEFAULT_APIVERSION.to_string(), + } + } +} + +impl Debug for SwVersion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} ({})", self.name, self.version) + } +} + +impl SwVersion { + #[must_use] + pub const fn new(version: u64, name: String) -> Self { + Self { version, name } + } + + #[must_use] + pub const fn as_u64(&self) -> u64 { + self.version + } + + #[must_use] + pub fn get_legacy_apiversion(&self) -> String { + let version = format!("{:05}", self.version); + format!("{}.{}.0", &version[0..1], &version[2..4]) + } + + #[must_use] + pub fn get_legacy_swversion(&self) -> String { + format!("{}", &self.version) + } + + #[must_use] + /// Format a version into the hue legacy format + /// + /// Legacy version is constructed from the version number. + /// + /// ```text + /// Example: + /// 1968096020 + /// + /// 1_68______ (these digits used) + /// + /// 1.68.1968096020 + /// ^^^^^^^^^^ append whole version number at the end + /// ``` + pub fn get_software_version(&self) -> String { + let version = format!("{:05}", self.version); + format!("{}.{}.{}", &version[0..1], &version[2..4], version) + } +} + +#[cfg(test)] +mod tests { + use crate::version::SwVersion; + use crate::{HUE_BRIDGE_V2_DEFAULT_APIVERSION, HUE_BRIDGE_V2_DEFAULT_SWVERSION}; + + #[allow(clippy::nonminimal_bool)] + #[test] + fn partial_ord() { + let a = SwVersion { + version: 10, + name: String::new(), + }; + let b = SwVersion { + version: 20, + name: String::new(), + }; + + assert!(a < b); + assert!(!(a >= b)); + } + + #[test] + fn default() { + let def = SwVersion::default(); + + assert_eq!( + def, + SwVersion { + version: HUE_BRIDGE_V2_DEFAULT_SWVERSION, + name: HUE_BRIDGE_V2_DEFAULT_APIVERSION.to_string(), + } + ); + } + + #[test] + fn debug() { + let version = SwVersion { + version: 1234, + name: "name".to_string(), + }; + assert_eq!(format!("{version:?}"), "name (1234)"); + } + + #[test] + fn as_u64() { + assert_eq!( + SwVersion::default().as_u64(), + HUE_BRIDGE_V2_DEFAULT_SWVERSION + ); + } + + #[test] + fn get_legacy_swversion() { + let version = SwVersion::new(1234, String::new()); + assert_eq!(version.get_legacy_swversion(), "1234"); + } + + #[test] + fn get_legacy_apiversion() { + let version = SwVersion::new(12345, String::new()); + assert_eq!(version.get_legacy_apiversion(), "1.34.0"); + } + + #[test] + fn get_software_version() { + let version = SwVersion::new(123_456, String::new()); + assert_eq!(version.get_software_version(), "1.34.123456"); + } +} diff --git a/crates/hue/src/xy.rs b/crates/hue/src/xy.rs new file mode 100644 index 0000000..b48caf9 --- /dev/null +++ b/crates/hue/src/xy.rs @@ -0,0 +1,255 @@ +use serde::{Deserialize, Serialize}; + +use crate::clamp::Clamp; +use crate::colorspace::{self, ColorSpace}; +use crate::hs::HS; +use crate::{WIDE_GAMUT_MAX_X, WIDE_GAMUT_MAX_Y}; + +#[derive(Copy, Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct XY { + pub x: f64, + pub y: f64, +} + +impl XY { + pub const COLOR_SPACE: ColorSpace = colorspace::WIDE; + + #[must_use] + pub const fn new(x: f64, y: f64) -> Self { + Self { x, y } + } + + pub const D50_WHITE_POINT: Self = Self { + x: 0.34567, + y: 0.35850, + }; + + pub const D65_WHITE_POINT: Self = Self { + x: 0.31271, + y: 0.32902, + }; + + #[must_use] + pub fn from_rgb(red: u8, green: u8, blue: u8) -> (Self, f64) { + let [r, g, b] = [red, green, blue].map(Clamp::unit_from_u8); + Self::from_rgb_unit(r, g, b) + } + + #[allow(clippy::many_single_char_names)] + #[must_use] + pub fn from_rgb_unit(r: f64, g: f64, b: f64) -> (Self, f64) { + let [x, y, b] = Self::COLOR_SPACE.rgb_to_xyy(r, g, b); + + let max_y = Self::COLOR_SPACE.find_maximum_y(x, y); + + if max_y > f64::EPSILON { + (Self { x, y }, (b / max_y * 255.0).min(255.0)) + } else { + (Self::D50_WHITE_POINT, 0.0) + } + } + + #[must_use] + pub fn from_hs(hs: HS) -> (Self, f64) { + let lightness: f64 = 0.5; + Self::from_hsl(hs, lightness) + } + + #[must_use] + pub fn from_hsl(hs: HS, lightness: f64) -> (Self, f64) { + let [r, g, b] = Self::rgb_from_hsl(hs, lightness); + Self::from_rgb_unit(r, g, b) + } + + #[must_use] + pub fn rgb_from_hsl(hs: HS, lightness: f64) -> [f64; 3] { + let c = (1.0 - (2.0f64.mul_add(lightness, -1.0)).abs()) * hs.sat; + let h = hs.hue * 6.0; + let x = c * (1.0 - (h % 2.0 - 1.0).abs()); + let m = lightness - c / 2.0; + + if h < 1.0 { + [m + c, m + x, m] + } else if h < 2.0 { + [m + x, m + c, m] + } else if h < 3.0 { + [m, m + c, m + x] + } else if h < 4.0 { + [m, m + x, m + c] + } else if h < 5.0 { + [m + x, m, m + c] + } else { + [m + c, m, m + x] + } + } + + #[must_use] + pub fn to_rgb(&self, brightness: f64) -> [u8; 3] { + Self::COLOR_SPACE + .xy_to_rgb_color(self.x, self.y, brightness) + .map(Clamp::unit_to_u8_clamped) + } +} + +impl XY { + #[must_use] + pub fn from_quant(data: [u8; 3]) -> Self { + let x0 = u16::from(data[0]) | (u16::from(data[1] & 0x0F) << 8); + let y0 = (u16::from(data[2]) << 4) | (u16::from(data[1] >> 4)); + + let x = f64::from(x0) * WIDE_GAMUT_MAX_X / f64::from(0xFFF); + let y = f64::from(y0) * WIDE_GAMUT_MAX_Y / f64::from(0xFFF); + + Self { x, y } + } + + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + #[must_use] + pub fn to_quant(&self) -> [u8; 3] { + let x = ((self.x * f64::from(0xFFF)) / WIDE_GAMUT_MAX_X) as u16; + let y = ((self.y * f64::from(0xFFF)) / WIDE_GAMUT_MAX_Y) as u16; + debug_assert!(x < 0x1000); + debug_assert!(y < 0x1000); + + [ + (x & 0xFF) as u8, + (((x >> 8) & 0x0F) | ((y & 0x0F) << 4)) as u8, + ((y >> 4) & 0xFF) as u8, + ] + } +} + +impl From<[f64; 2]> for XY { + fn from(value: [f64; 2]) -> Self { + Self { + x: value[0], + y: value[1], + } + } +} + +impl From for [f64; 2] { + fn from(value: XY) -> Self { + [value.x, value.y] + } +} + +#[cfg(test)] +mod tests { + use crate::hs::HS; + use crate::xy::XY; + use crate::{ + WIDE_GAMUT_MAX_X, WIDE_GAMUT_MAX_Y, compare, compare_float, compare_hsl_rgb, compare_rgb, + compare_xy, + }; + + #[test] + fn rgb_from_hsl() { + const ONE: f64 = 1.0; + let sat = 1.0; + + compare_hsl_rgb!(0.0 / 3.0, sat, [ONE, 0.0, 0.0]); // red + compare_hsl_rgb!(0.5 / 3.0, sat, [ONE, ONE, 0.0]); // red-green + compare_hsl_rgb!(1.0 / 3.0, sat, [0.0, ONE, 0.0]); // green + compare_hsl_rgb!(1.5 / 3.0, sat, [0.0, ONE, ONE]); // green-blue + compare_hsl_rgb!(2.0 / 3.0, sat, [0.0, 0.0, ONE]); // blue + compare_hsl_rgb!(2.5 / 3.0, sat, [ONE, 0.0, ONE]); // blue-red + compare_hsl_rgb!(3.0 / 3.0, sat, [ONE, 0.0, 0.0]); // red (wrapped around) + } + + #[test] + fn xy_from_f64() { + let a = XY::from([0.1, 0.2]); + let b = XY::new(0.1, 0.2); + + compare!(a.x, b.x); + compare!(a.y, b.y); + } + + #[test] + fn f64_from_xy() { + let a = [0.1, 0.2]; + let b = <[f64; 2]>::from(XY::new(0.1, 0.2)); + + compare!(a[0], b[0]); + compare!(a[1], b[1]); + } + + #[test] + fn xy_from_quant_max() { + let xy = XY::from_quant([0xFF, 0xFF, 0xFF]); + compare!(xy.x, WIDE_GAMUT_MAX_X); + compare!(xy.y, WIDE_GAMUT_MAX_Y); + } + + #[test] + fn xy_from_quant_zero() { + let xy = XY::from_quant([0x00, 0x00, 0x00]); + compare!(xy.x, 0.0); + compare!(xy.y, 0.0); + } + + #[test] + fn xy_from_quant_middle_x() { + let xy = XY::from_quant([0xFF, 0x07, 0x00]); + compare!(xy.x, WIDE_GAMUT_MAX_X / 2.0); + compare!(xy.y, 0.0); + } + + #[test] + fn xy_from_quant_middle_y() { + let xy = XY::from_quant([0x00, 0x00, 0x80]); + compare!(xy.x, 0.0); + compare!(xy.y, WIDE_GAMUT_MAX_Y / 2.0 + 0.0001); + } + + #[test] + fn xy_to_quant_middle_x() { + let xy = XY::new(WIDE_GAMUT_MAX_X / 2.0, 0.0); + + assert_eq!(xy.to_quant(), [0xFF, 0x07, 0x00]); + } + + #[test] + fn xy_to_quant_middle_y() { + let xy = XY::new(0.0, WIDE_GAMUT_MAX_Y / 2.0); + + assert_eq!(xy.to_quant(), [0x00, 0xF0, 0x7F]); + } + + #[test] + fn xy_from_rgb_unit_black() { + let (xy, b) = XY::from_rgb_unit(0.0, 0.0, 0.0); + compare!(b, 0.0); + compare!(xy.x, XY::D50_WHITE_POINT.x); + compare!(xy.y, XY::D50_WHITE_POINT.y); + } + + #[test] + fn xy_from_rgb_unit_white() { + let (xy, b) = XY::from_rgb_unit(1.0, 1.0, 1.0); + compare!(b, 255.0); + compare!(xy.x, XY::D50_WHITE_POINT.x); + compare!(xy.y, XY::D50_WHITE_POINT.y); + } + + #[test] + fn xy_to_rgb_white() { + let xy = XY::D50_WHITE_POINT; + assert_eq!(xy.to_rgb(255.0), [0xFF, 0xFF, 0xFF]); + } + + #[test] + fn xy_from_hs() { + let (xy, b) = XY::from_hs(HS { hue: 0.0, sat: 0.0 }); + compare_float!(b, 255.0 / 2.0, 1e-2); + compare_xy!(xy, XY::D50_WHITE_POINT); + } + + #[test] + fn xy_from_hsl() { + let (xy, b) = XY::from_hsl(HS { hue: 0.0, sat: 0.0 }, 1.0); + compare!(b, 255.0); + compare_xy!(xy, XY::D50_WHITE_POINT); + } +} diff --git a/crates/hue/src/zigbee/composite.rs b/crates/hue/src/zigbee/composite.rs new file mode 100644 index 0000000..3824678 --- /dev/null +++ b/crates/hue/src/zigbee/composite.rs @@ -0,0 +1,711 @@ +use std::io::{Cursor, Read, Write}; + +use bitflags::bitflags; +use byteorder::{LittleEndian as LE, ReadBytesExt, WriteBytesExt}; +use packed_struct::derive::{PackedStruct, PrimitiveEnum_u8}; +use packed_struct::{PackedStruct, PackedStructSlice, PrimitiveEnum}; + +use crate::api::{LightEffect, LightGradientMode, LightTimedEffect}; +use crate::effect_duration::EffectDuration; +use crate::error::{HueError, HueResult}; +use crate::flags::TakeFlag; +use crate::xy::XY; + +#[derive(PrimitiveEnum_u8, Debug, Copy, Clone)] +pub enum EffectType { + NoEffect = 0x00, + Candle = 0x01, + Fireplace = 0x02, + Prism = 0x03, + Sunrise = 0x09, + Sparkle = 0x0a, + Opal = 0x0b, + Glisten = 0x0c, + Sunset = 0x0d, + Underwater = 0x0e, + Cosmos = 0x0f, + Sunbeam = 0x10, + Enchant = 0x11, +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl From for EffectType { + fn from(value: LightEffect) -> Self { + match value { + LightEffect::NoEffect => Self::NoEffect, + LightEffect::Prism => Self::Prism, + LightEffect::Opal => Self::Opal, + LightEffect::Glisten => Self::Glisten, + LightEffect::Sparkle => Self::Sparkle, + LightEffect::Fire => Self::Fireplace, + LightEffect::Candle => Self::Candle, + LightEffect::Underwater => Self::Underwater, + LightEffect::Cosmos => Self::Cosmos, + LightEffect::Sunbeam => Self::Sunbeam, + LightEffect::Enchant => Self::Enchant, + } + } +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl From for EffectType { + fn from(value: LightTimedEffect) -> Self { + match value { + LightTimedEffect::NoEffect => Self::NoEffect, + LightTimedEffect::Sunrise => Self::Sunrise, + LightTimedEffect::Sunset => Self::Sunset, + } + } +} + +#[derive(PrimitiveEnum_u8, Debug, Copy, Clone, PartialEq, Eq)] +pub enum GradientStyle { + Linear = 0x00, + Scattered = 0x02, + Mirrored = 0x04, +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl From for GradientStyle { + fn from(value: LightGradientMode) -> Self { + match value { + LightGradientMode::InterpolatedPalette => Self::Linear, + LightGradientMode::InterpolatedPaletteMirrored => Self::Mirrored, + LightGradientMode::RandomPixelated => Self::Scattered, + } + } +} + +bitflags! { + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub struct Flags: u16 { + const ON_OFF = 1 << 0; + const BRIGHTNESS = 1 << 1; + const COLOR_MIREK = 1 << 2; + const COLOR_XY = 1 << 3; + const FADE_SPEED = 1 << 4; + const EFFECT_TYPE = 1 << 5; + const GRADIENT_PARAMS = 1 << 6; + const EFFECT_SPEED = 1 << 7; + const GRADIENT_COLORS = 1 << 8; + const UNUSED_9 = 1 << 9; + const UNUSED_A = 1 << 10; + const UNUSED_B = 1 << 11; + const UNUSED_C = 1 << 12; + const UNUSED_D = 1 << 13; + const UNUSED_E = 1 << 14; + const UNUSED_F = 1 << 15; + } +} + +#[derive(PackedStruct)] +#[packed_struct(endian = "lsb", bit_numbering = "msb0")] +pub struct GradientUpdateHeader { + /// First 4 bits of first byte: number of gradient light points + #[packed_field(bits = "0..4")] + pub nlights: u8, + + /// Last 4 bits of first byte: MUST BE 0 + #[packed_field(bits = "4..8")] + pub resv0: u8, + + /// Second byte: gradient style + #[packed_field(bytes = "1", ty = "enum")] + pub style: GradientStyle, + + /// Third and fourth byte: seems unused + #[packed_field(bytes = "2..=3")] + pub resv2: u16, +} + +pub struct GradientColors { + pub header: GradientUpdateHeader, + pub points: Vec, +} + +#[derive(Debug, PackedStruct)] +#[packed_struct(endian = "lsb")] +pub struct GradientParams { + pub scale: u8, + pub offset: u8, +} + +impl Default for GradientParams { + fn default() -> Self { + Self::new() + } +} + +impl GradientParams { + #[must_use] + pub const fn new() -> Self { + Self { + scale: 0x08, + offset: 0x00, + } + } +} + +#[derive(Default)] +pub struct HueZigbeeUpdate { + pub onoff: Option, + pub brightness: Option, + pub color_mirek: Option, + pub color_xy: Option, + pub fade_speed: Option, + pub gradient_colors: Option, + pub gradient_params: Option, + pub effect_type: Option, + pub effect_speed: Option, +} + +impl HueZigbeeUpdate { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub const fn is_empty(&self) -> bool { + self.onoff.is_none() + && self.brightness.is_none() + && self.color_mirek.is_none() + && self.color_xy.is_none() + && self.fade_speed.is_none() + && self.gradient_colors.is_none() + && self.gradient_params.is_none() + && self.effect_type.is_none() + && self.effect_speed.is_none() + } + + #[must_use] + pub const fn with_on_off(mut self, on_off: bool) -> Self { + self.onoff = Some(if on_off { 1 } else { 0 }); + self + } + + #[must_use] + pub const fn with_brightness(mut self, brightness: u8) -> Self { + self.brightness = Some(brightness); + self + } + + #[must_use] + pub const fn with_color_mirek(mut self, mirek: u16) -> Self { + self.color_mirek = Some(mirek); + self + } + + #[must_use] + pub const fn with_color_xy(mut self, xy: XY) -> Self { + self.color_xy = Some(xy); + self + } + + #[must_use] + pub const fn with_fade_speed(mut self, speed: u16) -> Self { + self.fade_speed = Some(speed); + self + } + + pub fn with_gradient_colors( + mut self, + style: GradientStyle, + points: Vec, + ) -> HueResult { + self.gradient_colors = Some(GradientColors { + header: GradientUpdateHeader { + nlights: u8::try_from(points.len())?, + resv0: 0, + style, + resv2: 0, + }, + points, + }); + Ok(self) + } + + #[must_use] + pub const fn with_gradient_params(mut self, transform: GradientParams) -> Self { + self.gradient_params = Some(transform); + self + } + + #[must_use] + pub const fn with_effect_type(mut self, effect_type: EffectType) -> Self { + self.effect_type = Some(effect_type); + self + } + + #[must_use] + pub const fn with_effect_speed(mut self, effect_speed: u8) -> Self { + self.effect_speed = Some(effect_speed); + self + } + + #[must_use] + pub const fn with_effect_duration(self, EffectDuration(effect_speed): EffectDuration) -> Self { + self.with_effect_speed(effect_speed) + } +} + +#[allow(clippy::cast_possible_truncation)] +impl HueZigbeeUpdate { + pub fn from_reader(rdr: &mut impl Read) -> HueResult { + let mut hz = Self::default(); + + let mut flags = Flags::from_bits(rdr.read_u16::()?).unwrap(); + + if flags.take(Flags::ON_OFF) { + hz.onoff = Some(rdr.read_u8()?); + } + + if flags.take(Flags::BRIGHTNESS) { + hz.brightness = Some(rdr.read_u8()?); + } + + if flags.take(Flags::COLOR_MIREK) { + hz.color_mirek = Some(rdr.read_u16::()?); + } + + if flags.take(Flags::COLOR_XY) { + hz.color_xy = Some(XY::new( + f64::from(rdr.read_u16::()?) / f64::from(0xFFFF), + f64::from(rdr.read_u16::()?) / f64::from(0xFFFF), + )); + } + + if flags.take(Flags::FADE_SPEED) { + hz.fade_speed = Some(rdr.read_u16::()?); + } + + if flags.take(Flags::EFFECT_TYPE) { + let data = rdr.read_u8()?; + hz.effect_type = + Some(EffectType::from_primitive(data).ok_or(HueError::HueZigbeeDecodeError)?); + } + + if flags.take(Flags::GRADIENT_COLORS) { + let len = rdr.read_u8()?; + let mut data = vec![0; 4]; + rdr.read_exact(&mut data)?; + let header = GradientUpdateHeader::unpack_from_slice(&data)?; + debug_assert!(len == header.nlights * 3 + 4); + + let mut points = vec![]; + for _ in 0..header.nlights { + let mut bytes = [0u8; 3]; + rdr.read_exact(&mut bytes)?; + points.push(XY::from_quant(bytes)); + } + hz.gradient_colors = Some(GradientColors { header, points }); + } + + if flags.take(Flags::EFFECT_SPEED) { + hz.effect_speed = Some(rdr.read_u8()?); + } + + if flags.take(Flags::GRADIENT_PARAMS) { + hz.gradient_params = Some(GradientParams { + scale: rdr.read_u8()?, + offset: rdr.read_u8()?, + }); + } + + if flags.is_empty() { + Ok(hz) + } else { + Err(HueError::HueZigbeeUnknownFlags(flags.bits())) + } + } +} + +#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] +impl HueZigbeeUpdate { + pub fn to_vec(&self) -> HueResult> { + let mut cur = Cursor::new(vec![]); + self.serialize(&mut cur)?; + Ok(cur.into_inner()) + } + + pub fn serialize(&self, wtr: &mut impl Write) -> HueResult<()> { + #[allow(clippy::ref_option)] + fn opt_to_flag(flags: &mut Flags, opt: &Option, flag: Flags) { + if opt.is_some() { + flags.insert(flag); + } + } + + let mut flags = Flags::empty(); + opt_to_flag(&mut flags, &self.onoff, Flags::ON_OFF); + opt_to_flag(&mut flags, &self.brightness, Flags::BRIGHTNESS); + opt_to_flag(&mut flags, &self.color_mirek, Flags::COLOR_MIREK); + opt_to_flag(&mut flags, &self.color_xy, Flags::COLOR_XY); + opt_to_flag(&mut flags, &self.fade_speed, Flags::FADE_SPEED); + opt_to_flag(&mut flags, &self.effect_type, Flags::EFFECT_TYPE); + opt_to_flag(&mut flags, &self.effect_speed, Flags::EFFECT_SPEED); + opt_to_flag(&mut flags, &self.gradient_colors, Flags::GRADIENT_COLORS); + opt_to_flag(&mut flags, &self.gradient_params, Flags::GRADIENT_PARAMS); + + wtr.write_u16::(flags.bits())?; + + if let Some(onoff) = self.onoff { + wtr.write_u8(onoff)?; + } + + if let Some(bright) = self.brightness { + wtr.write_u8(bright)?; + } + + if let Some(mirek) = self.color_mirek { + wtr.write_u16::(mirek)?; + } + + if let Some(xy) = self.color_xy { + wtr.write_u16::((xy.x * f64::from(0xFFFF)) as u16)?; + wtr.write_u16::((xy.y * f64::from(0xFFFF)) as u16)?; + } + + if let Some(fade_speed) = self.fade_speed { + wtr.write_u16::(fade_speed)?; + } + + if let Some(etype) = self.effect_type { + wtr.write_u8(etype.to_primitive())?; + } + + if let Some(grad_color) = &self.gradient_colors { + let len = u8::try_from(4 + 3 * grad_color.points.len())?; + wtr.write_u8(len)?; + wtr.write_all(&grad_color.header.pack()?)?; + for point in &grad_color.points { + wtr.write_all(&point.to_quant())?; + } + } + + if let Some(effect_speed) = self.effect_speed { + wtr.write_u8(effect_speed)?; + } + + if let Some(params) = &self.gradient_params { + wtr.write_u8(params.scale)?; + wtr.write_u8(params.offset)?; + } + + Ok(()) + } +} + +#[cfg_attr(coverage_nightly, coverage(off))] +#[cfg(test)] +mod tests { + use std::io::Cursor; + + use crate::error::HueError; + use crate::xy::XY; + use crate::zigbee::{EffectType, GradientParams, GradientStyle, HueZigbeeUpdate}; + use crate::{compare, compare_float, compare_xy, compare_xy_quant}; + + #[test] + fn hzb_none() { + let hz = HueZigbeeUpdate::new(); + let bytes = hz.to_vec().unwrap(); + + assert_eq!(bytes, &[0x00, 0x00]); + } + + #[test] + fn hzb_onoff() { + let hz = HueZigbeeUpdate::new().with_on_off(true); + let bytes = hz.to_vec().unwrap(); + + assert_eq!(bytes, &[0x01, 0x00, 0x01]); + } + + #[test] + fn hzb_brightness() { + let hz = HueZigbeeUpdate::new().with_brightness(0x42); + let bytes = hz.to_vec().unwrap(); + + assert_eq!(bytes, &[0x02, 0x00, 0x42]); + } + + #[test] + fn hzb_mirek() { + let hz = HueZigbeeUpdate::new().with_color_mirek(0x1234); + let bytes = hz.to_vec().unwrap(); + + assert_eq!(bytes, &[0x04, 0x00, 0x34, 0x12]); + } + + #[test] + fn hzb_xy() { + let hz = HueZigbeeUpdate::new().with_color_xy(XY::new(0.5, 1.0)); + let bytes = hz.to_vec().unwrap(); + + assert_eq!(bytes, &[0x08, 0x00, 0xFF, 0x7F, 0xFF, 0xFF]); + } + + #[test] + fn hzb_fade_speed() { + let hz = HueZigbeeUpdate::new().with_fade_speed(0x1234); + let bytes = hz.to_vec().unwrap(); + + assert_eq!(bytes, &[0x10, 0x00, 0x34, 0x12]); + } + + #[test] + fn hzb_effect_type() { + let hz = HueZigbeeUpdate::new().with_effect_type(EffectType::Candle); + let bytes = hz.to_vec().unwrap(); + + assert_eq!(bytes, &[0x20, 0x00, 0x01]); + } + + #[test] + fn hzb_gradient_empty() { + let hz = HueZigbeeUpdate::new() + .with_gradient_colors(GradientStyle::Scattered, vec![]) + .unwrap(); + let bytes = hz.to_vec().unwrap(); + assert_eq!( + bytes, + &[ + 0x00, 0x01, // flags + 0x04, // data length + 0x00, // number of lights (<< 4) + 0x02, // style: scattered + 0x00, 0x00 // padding + ] + ); + } + + #[test] + fn hzb_gradient_lights() { + let col1 = XY::new(0.5, 0.5); + let hz = HueZigbeeUpdate::new() + .with_gradient_colors(GradientStyle::Scattered, vec![col1]) + .unwrap(); + let bytes = hz.to_vec().unwrap(); + let quant = col1.to_quant(); + assert_eq!( + bytes, + &[ + 0x00, 0x01, // flags + 0x07, // data length + 0x10, // number of lights (<< 4) + 0x02, // style: scattered + 0x00, 0x00, // padding + quant[0], quant[1], quant[2], + ] + ); + } + + #[test] + fn hzb_gradient_too_many() { + let col = XY::new(0.5, 0.5); + let res = HueZigbeeUpdate::new() + .with_gradient_colors(GradientStyle::Scattered, [col].repeat(257)); + assert!(matches!(res, Err(HueError::TryFromIntError(_)))); + } + + #[test] + fn hzb_effect_speed() { + let hz = HueZigbeeUpdate::new().with_effect_speed(0xAB); + let bytes = hz.to_vec().unwrap(); + + assert_eq!(bytes, &[0x80, 0x00, 0xAB]); + } + + #[test] + fn hzb_gradient_params() { + let hz = HueZigbeeUpdate::new().with_gradient_params(GradientParams { + scale: 0x12, + offset: 0x34, + }); + let bytes = hz.to_vec().unwrap(); + + assert_eq!(bytes, &[0x40, 0x00, 0x12, 0x34]); + } + + #[test] + fn hzb_is_empty() { + use HueZigbeeUpdate as HZU; + assert!(HZU::new().is_empty()); + assert!(!HZU::new().with_on_off(false).is_empty()); + assert!(!HZU::new().with_brightness(0x01).is_empty()); + assert!(!HZU::new().with_color_mirek(0x01).is_empty()); + assert!(!HZU::new().with_color_xy(XY::D50_WHITE_POINT).is_empty()); + assert!(!HZU::new().with_color_xy(XY::D50_WHITE_POINT).is_empty()); + assert!(!HZU::new().with_effect_type(EffectType::Cosmos).is_empty(),); + assert!(!HZU::new().with_fade_speed(0x01).is_empty()); + assert!( + !HZU::new() + .with_gradient_colors(GradientStyle::Mirrored, vec![]) + .unwrap() + .is_empty(), + ); + assert!( + !HZU::new() + .with_gradient_params(GradientParams { + scale: 0x01, + offset: 0x02 + }) + .is_empty(), + ); + } + + #[test] + fn hzb_parse_eof() { + let data = []; + let mut cur = Cursor::new(data.as_slice()); + match HueZigbeeUpdate::from_reader(&mut cur) { + Ok(_) => panic!(), + Err(err) => assert!(matches!(err, HueError::IOError(_))), + } + } + + #[test] + fn hzb_parse_empty() { + let data = [0x00, 0x00]; + let mut cur = Cursor::new(data.as_slice()); + let res = HueZigbeeUpdate::from_reader(&mut cur).unwrap(); + + assert!(res.is_empty()); + } + + #[test] + fn hzb_parse_onoff() { + let data = [0x01, 0x00, 0x01]; + let mut cur = Cursor::new(data.as_slice()); + let mut res = HueZigbeeUpdate::from_reader(&mut cur).unwrap(); + + assert_eq!(res.onoff.take(), Some(0x01)); + assert!(res.is_empty()); + } + + #[test] + fn hzb_parse_brightness() { + let data = [0x02, 0x00, 0x42]; + let mut cur = Cursor::new(data.as_slice()); + let mut res = HueZigbeeUpdate::from_reader(&mut cur).unwrap(); + + assert_eq!(res.brightness.take(), Some(0x42)); + assert!(res.is_empty()); + } + + #[test] + fn hzb_parse_mirek() { + let data = [0x04, 0x00, 0x22, 0x11]; + let mut cur = Cursor::new(data.as_slice()); + let mut res = HueZigbeeUpdate::from_reader(&mut cur).unwrap(); + + assert_eq!(res.color_mirek.take(), Some(0x1122)); + assert!(res.is_empty()); + } + + #[test] + fn hzb_parse_xy() { + let data = [0x08, 0x00, 0xFF, 0x7F, 0xFF, 0xFF]; + let mut cur = Cursor::new(data.as_slice()); + let mut res = HueZigbeeUpdate::from_reader(&mut cur).unwrap(); + + let xy = res.color_xy.take().unwrap(); + compare_xy!(xy, XY::new(0.5, 1.0)); + assert!(res.is_empty()); + } + + #[test] + fn hzb_parse_fade_speed() { + let data = [0x10, 0x00, 0x22, 0x11]; + let mut cur = Cursor::new(data.as_slice()); + let mut res = HueZigbeeUpdate::from_reader(&mut cur).unwrap(); + + assert_eq!(res.fade_speed.take(), Some(0x1122)); + assert!(res.is_empty()); + } + + #[test] + fn hzb_parse_effect_type() { + let data = [0x20, 0x00, 0x01]; + let mut cur = Cursor::new(data.as_slice()); + let mut res = HueZigbeeUpdate::from_reader(&mut cur).unwrap(); + + assert_eq!( + res.effect_type.take().unwrap() as u8, + EffectType::Candle as u8 + ); + assert!(res.is_empty()); + } + + #[test] + fn hzb_parse_effect_speed() { + let data = [0x80, 0x00, 0xAB]; + let mut cur = Cursor::new(data.as_slice()); + let mut res = HueZigbeeUpdate::from_reader(&mut cur).unwrap(); + + assert_eq!(res.effect_speed.take().unwrap(), 0xAB); + assert!(res.is_empty()); + } + + #[test] + fn hzb_parse_gradient_params() { + let data = [0x40, 0x00, 0x12, 0x34]; + let mut cur = Cursor::new(data.as_slice()); + let mut res = HueZigbeeUpdate::from_reader(&mut cur).unwrap(); + + let params = res.gradient_params.take().unwrap(); + assert_eq!(params.scale, 0x12); + assert_eq!(params.offset, 0x34); + assert!(res.is_empty()); + } + + #[test] + fn hzb_parse_gradient_lights() { + let col1 = XY::new(0.70, 0.70); + + let quant = col1.to_quant(); + + let data = [ + 0x00, + 0x01, // flags + 0x07, // data length + 0x10, // number of lights (<< 4) + 0x02, // style: scattered + 0x00, + 0x00, // padding + quant[0] + 0x01, + quant[1], + quant[2], + ]; + let mut cur = Cursor::new(data.as_slice()); + let mut res = HueZigbeeUpdate::from_reader(&mut cur).unwrap(); + let gc = res.gradient_colors.take().unwrap(); + assert_eq!(gc.points.len(), 1); + assert_eq!(gc.header.nlights, 1); + assert_eq!(gc.header.resv0, 0); + assert_eq!(gc.header.resv2, 0); + assert_eq!(gc.header.style, GradientStyle::Scattered); + eprintln!("{:.4?}", gc.points[0]); + compare_xy_quant!(gc.points[0], col1); + assert!(res.is_empty()); + } + + #[test] + fn hzb_parse_unknown_flags() { + let data = [0x00, 0x20]; + let mut cur = Cursor::new(data.as_slice()); + match HueZigbeeUpdate::from_reader(&mut cur) { + Ok(_) => panic!(), + Err(err) => assert!(matches!(err, HueError::HueZigbeeUnknownFlags(_))), + } + } + + #[test] + fn grad_params_new_is_default() { + let a = GradientParams::new(); + let b = GradientParams::default(); + assert_eq!(a.offset, b.offset); + assert_eq!(a.scale, b.scale); + } +} diff --git a/crates/hue/src/zigbee/entertainment.rs b/crates/hue/src/zigbee/entertainment.rs new file mode 100644 index 0000000..14e2a30 --- /dev/null +++ b/crates/hue/src/zigbee/entertainment.rs @@ -0,0 +1,411 @@ +use std::fmt::Debug; +use std::io::Write; + +use byteorder::{BE, LE, WriteBytesExt}; +use packed_struct::prelude::*; + +use crate::xy::XY; + +use crate::error::{HueError, HueResult}; + +#[derive(PackedStruct, Debug, Clone, Copy)] +#[packed_struct(size = "6", endian = "lsb")] +pub struct HueEntStop { + pub x0: u8, + pub x1: u8, + pub counter: u32, +} + +#[derive(Debug, Clone)] +pub struct HueEntSegmentConfig { + pub members: Vec, +} + +#[derive(PackedStruct, Debug, Clone)] +#[packed_struct(size = "2", endian = "lsb")] +pub struct HueEntSegment { + pub length: u8, + pub index: u8, +} + +#[derive(Debug, Clone)] +pub struct HueEntSegmentLayout { + pub members: Vec, +} + +#[derive(PackedStruct, Debug, Clone)] +#[packed_struct(size = "6", endian = "lsb")] +pub struct HueEntFrameHeader { + pub counter: u32, + pub smoothing: u16, +} + +#[derive(Debug, Clone)] +pub struct HueEntFrame { + pub counter: u32, + pub smoothing: u16, + pub blks: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LightRecordMode { + Segment = 0b00000, + Device = 0b01011, +} + +#[derive(PackedStruct, Clone)] +#[packed_struct(size_bytes = "7", endian = "lsb", bit_numbering = "msb0")] +pub struct HueEntFrameLightRecord { + /// Zigbee network address of recipient + #[packed_field(bits = "0..=15")] + addr: u16, + + /// Field contains brightness (top 11 bits) and mode (bottom 5 bits) + brightness: u16, + + /// Raw (packed) color value (from [`XY::to_quant()`]) + #[packed_field(bits = "32..=55")] + raw: [u8; 3], +} + +impl HueEntFrameLightRecord { + #[must_use] + pub const fn new(addr: u16, brightness: u16, mode: LightRecordMode, raw: [u8; 3]) -> Self { + Self { + addr, + brightness: (brightness << 5) | (mode as u16), + raw, + } + } + + #[must_use] + pub const fn brightness(&self) -> u16 { + self.brightness >> 5 + } + + #[must_use] + pub const fn mode(&self) -> Option { + match self.brightness & 0x1F { + val if val == LightRecordMode::Device as u16 => Some(LightRecordMode::Device), + val if val == LightRecordMode::Segment as u16 => Some(LightRecordMode::Segment), + _ => None, + } + } + + #[must_use] + pub const fn raw(&self) -> [u8; 3] { + self.raw + } +} + +impl Debug for HueEntFrameLightRecord { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let xy = XY::from_quant(self.raw); + + write!( + f, + "<{:04x}> ({:.3?},{:.3?})@{:04x?}", + self.addr, xy.x, xy.y, self.brightness + ) + } +} + +const fn check_size_valid(len: usize, header_size: usize, element_size: usize) -> HueResult<()> { + // Must have bytes enough for the header + if len < header_size { + return Err(HueError::HueZigbeeDecodeError); + } + + // Must have a whole number of elements + if (len - header_size) % element_size != 0 { + return Err(HueError::HueZigbeeDecodeError); + } + + Ok(()) +} + +impl HueEntSegmentConfig { + #[must_use] + pub fn new(map: &[u16]) -> Self { + Self { + members: map.to_vec(), + } + } + + pub fn parse(data: &[u8]) -> HueResult { + check_size_valid(data.len(), 2, 2)?; + + let (hdr, data) = data.split_at(2); + + let count = u16::from_be_bytes([hdr[0], hdr[1]]); + + let members = data + .chunks_exact(2) + .take(count as usize) + .map(|d| u16::from_le_bytes([d[0], d[1]])) + .collect(); + + Ok(Self { members }) + } + + pub fn pack(&self) -> HueResult> { + let mut res = vec![]; + let count = u16::try_from(self.members.len())?; + res.write_u16::(count)?; + for m in &self.members { + res.write_u16::(*m)?; + } + + Ok(res) + } +} + +impl HueEntSegmentLayout { + #[must_use] + pub fn new(map: &[HueEntSegment]) -> Self { + Self { + members: map.to_vec(), + } + } + + pub fn parse(data: &[u8]) -> HueResult { + check_size_valid(data.len(), 3, 2)?; + + let (hdr, data) = data.split_at(3); + + let count = hdr[2]; + + let members = data + .chunks_exact(2) + .take(usize::from(count)) + .map(HueEntSegment::unpack_from_slice) + .collect::>()?; + + Ok(Self { members }) + } + + pub fn pack(&self) -> HueResult> { + let mut res = vec![]; + let count = u8::try_from(self.members.len())?; + res.write_u16::(0)?; + res.write_u8(count)?; + for m in &self.members { + res.write_all(&m.pack()?)?; + } + + Ok(res) + } +} + +impl HueEntFrame { + pub fn parse(data: &[u8]) -> HueResult { + if data.len() < 6 { + return Err(HueError::HueZigbeeDecodeError); + } + + let (hdr, data) = data.split_at(6); + let hdr = HueEntFrameHeader::unpack_from_slice(hdr)?; + + let blks = data + .chunks_exact(7) + .map(HueEntFrameLightRecord::unpack_from_slice) + .collect::>()?; + + Ok(Self { + counter: hdr.counter, + smoothing: hdr.smoothing, + blks, + }) + } + + pub fn pack(&self) -> HueResult> { + let hdr = HueEntFrameHeader { + counter: self.counter, + smoothing: self.smoothing, + }; + + let mut res = hdr.pack_to_vec()?; + + for blk in &self.blks { + res.extend(&blk.pack()?); + } + + Ok(res) + } +} + +#[cfg(test)] +mod tests { + use packed_struct::prelude::*; + + use crate::error::HueError; + use crate::xy::XY; + use crate::zigbee::{ + HueEntFrame, HueEntFrameLightRecord, HueEntSegment, HueEntSegmentConfig, + HueEntSegmentLayout, LightRecordMode, + }; + + #[test] + fn light_record() { + let foo = HueEntFrameLightRecord { + addr: 0x1122, + brightness: 0x7FF << 5, + raw: [0xAA, 0xBB, 0xCC], + }; + + let data = foo.pack().unwrap(); + + assert_eq!("2211e0ffaabbcc", hex::encode(data)); + } + + #[test] + fn light_record_segment() { + let foo = HueEntFrameLightRecord::new( + 0x1122, + 0x7FF, + LightRecordMode::Segment, + [0xAA, 0xBB, 0xCC], + ); + + let data = foo.pack().unwrap(); + + assert_eq!("2211e0ffaabbcc", hex::encode(data)); + } + + #[test] + fn light_record_device() { + let foo = + HueEntFrameLightRecord::new(0x1122, 0x7FF, LightRecordMode::Device, [0xAA, 0xBB, 0xCC]); + + let data = foo.pack().unwrap(); + + assert_eq!("2211ebffaabbcc", hex::encode(data)); + } + + #[test] + fn light_brightness() { + let data = hex::decode("2211e0ffaabbcc").unwrap(); + let rec = HueEntFrameLightRecord::unpack_from_slice(&data).unwrap(); + assert_eq!(rec.addr, 0x1122); + assert_eq!(rec.brightness(), 0x7FF); + } + + #[test] + fn light_raw() { + let val = HueEntFrameLightRecord { + addr: 0, + brightness: 0, + raw: [1, 2, 3], + }; + assert_eq!(val.raw(), [1, 2, 3]); + } + + #[test] + fn light_record_mode() { + let val = HueEntFrameLightRecord { + addr: 0, + brightness: LightRecordMode::Device as u16, + raw: [0, 0, 0], + }; + assert_eq!(val.mode(), Some(LightRecordMode::Device)); + + let val = HueEntFrameLightRecord { + addr: 0, + brightness: LightRecordMode::Segment as u16, + raw: [0, 0, 0], + }; + assert_eq!(val.mode(), Some(LightRecordMode::Segment)); + + let val = HueEntFrameLightRecord { + addr: 0, + brightness: 1, + raw: [0, 0, 0], + }; + assert_eq!(val.mode(), None); + } + + #[test] + fn light_debug() { + let xy = XY::new(0.1, 0.2); + let val = + HueEntFrameLightRecord::new(0x1234, 0x7FF, LightRecordMode::Device, xy.to_quant()); + assert_eq!(format!("{val:?}"), "<1234> (0.100,0.200)@ffeb"); + } + + #[test] + fn hue_ent_segment_config() { + let cfg = HueEntSegmentConfig::new(&[1, 2, 3, 4]); + let data = cfg.pack().unwrap(); + assert_eq!(data, [0x00, 0x04, 1, 0, 2, 0, 3, 0, 4, 0]); + + let rev = HueEntSegmentConfig::parse(&data).unwrap(); + assert_eq!(rev.members, [1, 2, 3, 4]); + } + + #[test] + fn hue_ent_segment_layout_invalid() { + let err = HueEntSegmentLayout::parse(&[0x00, 0x00]).unwrap_err(); + assert!(matches!(err, HueError::HueZigbeeDecodeError)); + } + + #[test] + fn hue_ent_segment_layout_odd() { + let err = HueEntSegmentLayout::parse(&[0x00, 0x00, 0x01, 0xAA]).unwrap_err(); + assert!(matches!(err, HueError::HueZigbeeDecodeError)); + } + + #[test] + fn hue_ent_segment_layout() { + let cfg = HueEntSegmentLayout::new(&[HueEntSegment { + length: 10, + index: 20, + }]); + let data = cfg.pack().unwrap(); + assert_eq!(data, [0x00, 0x00, 1, 10, 20]); + + let rev = HueEntSegmentLayout::parse(&data).unwrap(); + assert_eq!(rev.members.len(), 1); + assert_eq!(rev.members[0].length, 10); + assert_eq!(rev.members[0].index, 20); + } + + #[test] + fn hue_ent_frame_invalid() { + let data = [0x44, 0x33, 0x22, 0x11]; + let err = HueEntFrame::parse(&data).unwrap_err(); + assert!(matches!(err, HueError::HueZigbeeDecodeError)); + } + + #[test] + fn hue_ent_frame() { + let cfg = HueEntFrame { + counter: 0x11_22_33_44, + smoothing: 0xAA_BB, + blks: vec![HueEntFrameLightRecord { + addr: 0x7788, + brightness: 0x123, + raw: [0xCC, 0xDD, 0xEE], + }], + }; + let data = cfg.pack().unwrap(); + + assert_eq!( + data, + [ + 0x44, 0x33, 0x22, 0x11, // counter + 0xBB, 0xAA, // smoothing + 0x88, 0x77, // addr + 0x23, 0x01, // brightness + 0xCC, 0xDD, 0xEE // raw + ] + ); + + let rev = HueEntFrame::parse(&data).unwrap(); + assert_eq!(rev.counter, 0x11_22_33_44); + assert_eq!(rev.smoothing, 0xAA_BB); + assert_eq!(rev.blks.len(), 1); + assert_eq!(rev.blks[0].addr, 0x7788); + assert_eq!(rev.blks[0].brightness, 0x123); + assert_eq!(rev.blks[0].raw(), [0xCC, 0xDD, 0xEE]); + } +} diff --git a/crates/hue/src/zigbee/mod.rs b/crates/hue/src/zigbee/mod.rs new file mode 100644 index 0000000..a07161d --- /dev/null +++ b/crates/hue/src/zigbee/mod.rs @@ -0,0 +1,9 @@ +mod composite; +mod entertainment; +mod stream; +mod target; + +pub use composite::*; +pub use entertainment::*; +pub use stream::*; +pub use target::*; diff --git a/crates/hue/src/zigbee/stream.rs b/crates/hue/src/zigbee/stream.rs new file mode 100644 index 0000000..c1b5699 --- /dev/null +++ b/crates/hue/src/zigbee/stream.rs @@ -0,0 +1,297 @@ +use chrono::Duration; +use packed_struct::prelude::*; + +use crate::error::{HueError, HueResult}; +use crate::zigbee::{HueEntFrame, HueEntFrameLightRecord, HueEntSegmentConfig, HueEntStop}; + +pub struct EntertainmentZigbeeStream { + smoothing: u16, + counter: u32, +} + +pub const PHILIPS_HUE_ZIGBEE_VENDOR_ID: u16 = 0x100B; + +#[derive(Debug, Clone)] +pub struct ZigbeeMessage { + /// Zigbee cluster id + pub cluster: u16, + + /// Zigbee command id + pub command: u8, + + /// Zigbee Zcl data bytes + pub data: Vec, + + /// Disable default response + pub ddr: bool, + + /// Frametype + pub frametype: u8, + + /// Manufacturer Code + pub mfc: Option, +} + +impl ZigbeeMessage { + #[must_use] + pub const fn new(cluster: u16, command: u8, data: Vec) -> Self { + Self { + cluster, + command, + data, + frametype: 1, + ddr: true, + mfc: Some(PHILIPS_HUE_ZIGBEE_VENDOR_ID), + } + } + + #[must_use] + pub fn with_ddr(self, ddr: bool) -> Self { + Self { ddr, ..self } + } + + #[must_use] + pub fn with_mfc(self, mfc: Option) -> Self { + Self { mfc, ..self } + } +} + +impl Default for EntertainmentZigbeeStream { + fn default() -> Self { + Self::new(0) + } +} + +impl EntertainmentZigbeeStream { + pub const DEFAULT_SMOOTHING: u16 = 0x0400; + pub const CLUSTER: u16 = 0xFC01; + pub const CMD_FRAME: u8 = 1; + pub const CMD_RESET: u8 = 3; + pub const CMD_LIGHT_BALANCE: u8 = 5; + pub const CMD_SEGMENT_MAP: u8 = 7; + + /// The maximum fade time (0xFFFF) seems to correspond to 2.56 seconds. + /// (determined experimentally) + pub const SMOOTHING_MAX_MICROS: i64 = 2_560_000; + + #[must_use] + pub const fn new(counter: u32) -> Self { + Self { + smoothing: Self::DEFAULT_SMOOTHING, + counter, + } + } + + #[must_use] + pub const fn counter(&self) -> u32 { + self.counter + } + + #[must_use] + pub const fn smoothing(&self) -> u16 { + self.smoothing + } + + pub const fn set_smoothing(&mut self, smoothing: u16) { + self.smoothing = smoothing; + } + + #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] + pub fn duration_to_smoothing(duration: Duration) -> HueResult { + // Get number of microseconds, if positive and less than maximum + let us = duration + .num_microseconds() + .filter(|us| (0..Self::SMOOTHING_MAX_MICROS).contains(us)) + .ok_or(HueError::HueZigbeeEncodeError)?; + + // Scale to target range + let smoothing = (us * 0x10000 / Self::SMOOTHING_MAX_MICROS) as u16; + + Ok(smoothing) + } + + pub fn set_smoothing_duration(&mut self, duration: Duration) -> HueResult<()> { + self.set_smoothing(Self::duration_to_smoothing(duration)?); + Ok(()) + } + + pub fn segment_mapping(&mut self, map: &[u16]) -> HueResult { + let msg = HueEntSegmentConfig::new(map); + + Ok(ZigbeeMessage::new( + Self::CLUSTER, + Self::CMD_SEGMENT_MAP, + msg.pack()?, + )) + } + + pub fn reset(&mut self) -> HueResult { + let ent = HueEntStop { + x0: 0, + x1: 1, + counter: self.counter, + }; + + Ok(ZigbeeMessage::new( + Self::CLUSTER, + Self::CMD_RESET, + ent.pack_to_vec()?, + )) + } + + pub fn frame(&mut self, blks: Vec) -> HueResult { + let ent = HueEntFrame { + counter: self.counter, + smoothing: self.smoothing, + blks, + }; + + self.counter += 1; + + Ok(ZigbeeMessage::new( + Self::CLUSTER, + Self::CMD_FRAME, + ent.pack()?, + )) + } +} + +#[cfg(test)] +mod tests { + + use chrono::Duration; + + use crate::zigbee::{ + EntertainmentZigbeeStream as EZS, PHILIPS_HUE_ZIGBEE_VENDOR_ID, ZigbeeMessage, + }; + + #[allow(clippy::bool_assert_comparison)] + #[test] + fn zigbee_message() { + let zb = ZigbeeMessage::new(0x1122, 0x33, vec![0x44, 0x55]); + assert_eq!(zb.cluster, 0x1122); + assert_eq!(zb.command, 0x33); + assert_eq!(zb.data, [0x44, 0x55]); + assert_eq!(zb.ddr, true); + assert_eq!(zb.frametype, 1); + assert_eq!(zb.mfc, Some(PHILIPS_HUE_ZIGBEE_VENDOR_ID)); + + let zb = zb.with_ddr(false); + assert_eq!(zb.ddr, false); + + let zb = zb.with_mfc(None); + assert_eq!(zb.mfc, None); + + let zb = zb.with_mfc(Some(0x1234)); + assert_eq!(zb.mfc, Some(0x1234)); + } + + #[test] + fn entertainment_zigbee_stream_default() { + let val = EZS::default(); + assert_eq!(val.counter, 0); + assert_eq!(val.smoothing, EZS::DEFAULT_SMOOTHING); + } + + #[test] + fn entertainment_zigbee_stream() { + let mut val = EZS { + smoothing: 0x1122, + counter: 0x11_22_33_44, + }; + assert_eq!(val.counter(), 0x11_22_33_44); + assert_eq!(val.smoothing(), 0x1122); + + val.set_smoothing(0x3344); + assert_eq!(val.smoothing(), 0x3344); + + val.set_smoothing_duration(Duration::milliseconds(0)) + .unwrap(); + assert_eq!(val.smoothing(), 0); + } + + #[test] + fn ezs_reset() { + let mut ezs = EZS::new(0x1122); + + let rst = ezs.reset().unwrap(); + assert_eq!(rst.cluster, EZS::CLUSTER); + assert_eq!(rst.command, EZS::CMD_RESET); + assert_eq!(rst.data, [0x00, 0x01, 0x22, 0x11, 0x00, 0x00]); + + // counter should be the same + assert_eq!(ezs.counter(), 0x1122); + } + + #[allow(clippy::bool_assert_comparison, clippy::cast_possible_truncation)] + #[test] + fn ezs_frame() { + let mut ezs = EZS::new(0x1122); + + let rst = ezs.frame(vec![]).unwrap(); + assert_eq!(rst.cluster, EZS::CLUSTER); + assert_eq!(rst.command, EZS::CMD_FRAME); + assert_eq!( + rst.data, + [ + 0x22, + 0x11, + 0x00, + 0x00, + ezs.smoothing as u8, + (ezs.smoothing >> 8) as u8 + ] + ); + + // counter should be incremented + assert_eq!(ezs.counter(), 0x1123); + } + + #[test] + fn ezs_segment_mapping() { + let mut ezs = EZS::new(0x1122); + + let rst = ezs.segment_mapping(&[0xA0A1, 0xB0B1]).unwrap(); + assert_eq!(rst.cluster, EZS::CLUSTER); + assert_eq!(rst.command, EZS::CMD_SEGMENT_MAP); + assert_eq!(rst.data, [0x00, 0x02, 0xA1, 0xA0, 0xB1, 0xB0]); + + // counter should be the same + assert_eq!(ezs.counter(), 0x1122); + } + + #[test] + fn duration_zero() { + let zero_dur = Duration::seconds(0); + let smo = EZS::duration_to_smoothing(zero_dur).unwrap(); + assert_eq!(smo, 0); + } + + #[test] + fn duration_half() { + let max_dur = Duration::microseconds(EZS::SMOOTHING_MAX_MICROS / 2); + let smo = EZS::duration_to_smoothing(max_dur).unwrap(); + assert_eq!(smo, 0x8000); + } + + #[test] + fn duration_max() { + let max_dur = Duration::microseconds(EZS::SMOOTHING_MAX_MICROS - 1); + let smo = EZS::duration_to_smoothing(max_dur).unwrap(); + assert_eq!(smo, 0xFFFF); + } + + #[test] + fn duration_negative() { + let max_dur = Duration::microseconds(-1); + let smo = EZS::duration_to_smoothing(max_dur); + assert!(smo.is_err()); + } + + #[test] + fn duration_over_limit() { + let max_dur = Duration::microseconds(EZS::SMOOTHING_MAX_MICROS); + let smo = EZS::duration_to_smoothing(max_dur); + assert!(smo.is_err()); + } +} diff --git a/crates/hue/src/zigbee/target.rs b/crates/hue/src/zigbee/target.rs new file mode 100644 index 0000000..e64b286 --- /dev/null +++ b/crates/hue/src/zigbee/target.rs @@ -0,0 +1,13 @@ +use crate::zigbee::ZigbeeMessage; + +/// A target for Zigbee requests +/// +/// Receives commands, and processes them in a target-specific manner. +pub trait ZigbeeTarget { + /// The result type when sending Zigbee commands. This could be a data + /// structure, a handle, `()`, or whatever makes sense for the impl. + type Error; + type Output; + + fn send(&mut self, msg: ZigbeeMessage) -> Result; +} diff --git a/crates/svc/Cargo.toml b/crates/svc/Cargo.toml new file mode 100644 index 0000000..161b007 --- /dev/null +++ b/crates/svc/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "svc" +version = "0.1.0" +edition.workspace = true +authors.workspace = true +rust-version.workspace = true +description.workspace = true +readme.workspace = true +repository.workspace = true +license.workspace = true +categories.workspace = true +keywords.workspace = true + +[dependencies] +async-trait = "0.1.86" +futures = { version = "0.3.31", default-features = false, features = ["alloc"] } +log = { version = "0.4.26", optional = true } +serde = { version = "1.0.218", features = ["derive"] } +thiserror = "2.0.11" +tokio = { version = "1.43.0", features = ["io-util", "macros", "process", "rt", "rt-multi-thread", "sync", "time", "tokio-macros"], optional = true } +uuid = { version = "1.14.0", features = [] } + +[features] +default = ["manager"] + +manager = ["dep:log", "dep:tokio", "uuid/v4"] + +[lints] +workspace = true + +[dev-dependencies] +pretty_env_logger = "0.5.0" diff --git a/crates/svc/examples/from_async_fn.rs b/crates/svc/examples/from_async_fn.rs new file mode 100644 index 0000000..574619c --- /dev/null +++ b/crates/svc/examples/from_async_fn.rs @@ -0,0 +1,50 @@ +use std::time::Duration; + +use thiserror::Error; +use tokio::time::sleep; + +use svc::error::SvcResult; +use svc::manager::ServiceManager; + +#[derive(Error, Debug)] +pub enum SimpleError { + #[error("That didn't work")] + Nope, +} + +async fn run() -> Result<(), SimpleError> { + let dur = Duration::from_millis(800); + + println!("Hello"); + + println!("1"); + sleep(dur).await; + println!("2"); + sleep(dur).await; + println!("3"); + + Ok(()) +} + +#[tokio::main] +async fn main() -> SvcResult<()> { + pretty_env_logger::formatted_builder() + .filter_level(log::LevelFilter::Debug) + .parse_default_env() + .init(); + + let (mut client, future) = ServiceManager::spawn(); + + client.register_function("foo", run()).await?; + client.start("foo").await?; + println!("main: service configured"); + + client.wait_for_start("foo").await?; + println!("main: service started"); + + client.shutdown().await?; + future.await??; + println!("main: service stopped"); + + Ok(()) +} diff --git a/crates/svc/examples/policy.rs b/crates/svc/examples/policy.rs new file mode 100644 index 0000000..b8c56ed --- /dev/null +++ b/crates/svc/examples/policy.rs @@ -0,0 +1,70 @@ +use std::time::Duration; + +use async_trait::async_trait; +use svc::policy::{Policy, Retry}; +use svc::runservice::StandardService; +use thiserror::Error; + +use svc::error::SvcResult; +use svc::manager::ServiceManager; +use svc::traits::{Service, ServiceState}; + +#[derive(Clone)] +struct PolicyService { + counter: u32, +} + +#[derive(Error, Debug)] +pub enum Error { + #[error("Not done yet")] + MoreToDo, +} + +#[async_trait] +impl Service for PolicyService { + type Error = Error; + + async fn run(&mut self) -> Result<(), Error> { + println!("Hello {}", self.counter); + self.counter += 1; + + // returning an Err will invoke the policy for the Running state + Err(Error::MoreToDo) + } +} + +#[tokio::main] +async fn main() -> SvcResult<()> { + const NAME: &str = "policy-service"; + + pretty_env_logger::formatted_builder() + .filter_level(log::LevelFilter::Debug) + .parse_default_env() + .init(); + + let (mut client, future) = ServiceManager::spawn(); + + let svc = PolicyService { counter: 0 }; + + // Manually construct a ServiceRunner, and set a specific policy for + // handling errors during .run() + let svcr = StandardService::new(NAME, svc).with_run_policy( + // Try up to 5 times, waiting 300ms between each attempt + Policy::new() + .with_retry(Retry::Limit(5)) + .with_delay(Duration::from_millis(300)), + ); + + let uuid = client.register(NAME, svcr).await?; + client.start(uuid).await?; + println!("main: service will attempt to run 5 times"); + + client.wait_for_start(uuid).await?; + println!("main: service started"); + + client.wait_for_state(uuid, ServiceState::Failed).await?; + client.shutdown().await?; + future.await??; + + Ok(()) +} diff --git a/crates/svc/examples/restart.rs b/crates/svc/examples/restart.rs new file mode 100644 index 0000000..c3b63f3 --- /dev/null +++ b/crates/svc/examples/restart.rs @@ -0,0 +1,56 @@ +use std::time::Duration; + +use tokio::time::sleep; + +use svc::error::{RunSvcError, SvcResult}; +use svc::manager::ServiceManager; + +async fn run() -> Result<(), RunSvcError> { + let dur = Duration::from_millis(200); + + println!("Hello"); + + let mut counter = 0; + + loop { + println!("{counter}"); + sleep(dur).await; + counter += 1; + } +} + +#[tokio::main] +async fn main() -> SvcResult<()> { + pretty_env_logger::formatted_builder() + .filter_level(log::LevelFilter::Debug) + .parse_default_env() + .init(); + + let (mut client, future) = ServiceManager::spawn(); + + client.register_function("foo", run()).await?; + + client.start("foo").await?; + println!("main: service configured"); + + client.wait_for_start("foo").await?; + println!("main: service started"); + + sleep(Duration::from_millis(1000)).await; + + client.stop("foo").await?; + client.wait_for_stop("foo").await?; + + println!("main: service stopped"); + + client.start("foo").await?; + client.wait_for_start("foo").await?; + println!("main: service started"); + + sleep(Duration::from_millis(1000)).await; + client.shutdown().await?; + + future.await??; + + Ok(()) +} diff --git a/crates/svc/examples/simple.rs b/crates/svc/examples/simple.rs new file mode 100644 index 0000000..9704233 --- /dev/null +++ b/crates/svc/examples/simple.rs @@ -0,0 +1,86 @@ +use std::time::Duration; + +use async_trait::async_trait; +use thiserror::Error; +use tokio::time::sleep; + +use svc::error::SvcResult; +use svc::manager::ServiceManager; +use svc::traits::Service; + +#[derive(Clone)] +struct Simple { + name: String, + counter: u32, +} + +#[derive(Error, Debug)] +pub enum SimpleError { + #[error("That didn't work..")] + Nope, +} + +#[async_trait] +impl Service for Simple { + type Error = SimpleError; + + async fn run(&mut self) -> Result<(), SimpleError> { + let dur = Duration::from_millis(300); + + println!("Hello from {}", self.name); + + println!("1"); + sleep(dur).await; + println!("2"); + sleep(dur).await; + println!("3"); + + println!("Done running. Now going to stop (this will fail the first time)"); + Ok(()) + } + + async fn stop(&mut self) -> Result<(), SimpleError> { + self.counter += 1; + + // pretend this service doesn't succeed at stopping right away + if self.counter > 1 { + Ok(()) + } else { + Err(SimpleError::Nope) + } + } +} + +#[tokio::main] +async fn main() -> SvcResult<()> { + pretty_env_logger::formatted_builder() + .filter_level(log::LevelFilter::Debug) + .parse_default_env() + .init(); + + let (mut client, future) = ServiceManager::spawn(); + + let svc = Simple { + name: "Simple Service".to_string(), + counter: 0, + }; + + client.register_service("foo", svc).await?; + client.start("foo").await?; + + println!("main: service configured"); + + client.wait_for_start("foo").await?; + + println!("main: service started"); + + client.wait_for_stop("foo").await?; + + println!("main: service stopped"); + + client.shutdown().await?; + + future.await??; + + Ok(()) +} diff --git a/crates/svc/src/error.rs b/crates/svc/src/error.rs new file mode 100644 index 0000000..aabe699 --- /dev/null +++ b/crates/svc/src/error.rs @@ -0,0 +1,84 @@ +use std::error::Error; + +use thiserror::Error; + +use crate::manager::{ServiceEvent, SvmRequest}; +use crate::serviceid::{ServiceId, ServiceName}; +use crate::traits::ServiceState; + +#[derive(Error, Debug)] +pub enum SvcError { + /* mapped errors */ + #[error(transparent)] + FromUtf8Error(#[from] std::string::FromUtf8Error), + + #[error(transparent)] + IOError(#[from] std::io::Error), + + #[error(transparent)] + TryFromIntError(#[from] std::num::TryFromIntError), + + #[error(transparent)] + UuidError(#[from] uuid::Error), + + #[error(transparent)] + JoinError(#[from] tokio::task::JoinError), + + #[error(transparent)] + MpscSendError(#[from] tokio::sync::mpsc::error::SendError), + + #[error(transparent)] + MpscSendEventError(#[from] tokio::sync::mpsc::error::SendError), + + #[error(transparent)] + WatchSendError(#[from] tokio::sync::watch::error::SendError), + + #[error(transparent)] + WatchRecvError(#[from] tokio::sync::watch::error::RecvError), + + #[error(transparent)] + OneshotRecvError(#[from] tokio::sync::oneshot::error::RecvError), + + #[error("Service {0:?} not registered")] + ServiceNotFound(ServiceId), + + #[error("Service {0} already exists")] + ServiceAlreadyExists(ServiceName), + + #[error("All services stopped")] + Shutdown, + + #[error("Service has failed")] + ServiceFailed, + + #[error("Templated service generation failed")] + ServiceGeneration(Box), +} + +impl SvcError { + pub fn generation(err: impl Error + Send + 'static) -> Self { + Self::ServiceGeneration(Box::new(err)) + } +} + +#[derive(Error, Debug)] +pub enum RunSvcError { + /* mapped errors */ + #[error(transparent)] + MpscSendError(#[from] tokio::sync::mpsc::error::SendError), + + #[error(transparent)] + WatchSendError(#[from] tokio::sync::watch::error::SendError), + + #[error(transparent)] + MpscSendEventError(#[from] tokio::sync::mpsc::error::SendError), + + #[error(transparent)] + WatchRecvError(#[from] tokio::sync::watch::error::RecvError), + + /* errors from run service */ + #[error(transparent)] + ServiceError(Box), +} + +pub type SvcResult = Result; diff --git a/crates/svc/src/lib.rs b/crates/svc/src/lib.rs new file mode 100644 index 0000000..f7ba6a0 --- /dev/null +++ b/crates/svc/src/lib.rs @@ -0,0 +1,14 @@ +pub mod policy; +pub mod serviceid; +pub mod traits; + +#[cfg(feature = "manager")] +pub mod error; +#[cfg(feature = "manager")] +pub mod manager; +#[cfg(feature = "manager")] +pub mod rpc; +#[cfg(feature = "manager")] +pub mod runservice; +#[cfg(feature = "manager")] +pub mod template; diff --git a/crates/svc/src/manager.rs b/crates/svc/src/manager.rs new file mode 100644 index 0000000..5b31cde --- /dev/null +++ b/crates/svc/src/manager.rs @@ -0,0 +1,574 @@ +#![allow(clippy::future_not_send)] +//! A [`ServiceManager`] manages a collection of [`Service`] instances. +use std::collections::{BTreeMap, BTreeSet}; +use std::error::Error; +use std::fmt::Debug; +use std::future::Future; +use std::time::Duration; + +use futures::future::BoxFuture; +use tokio::select; +use tokio::sync::{mpsc, watch}; +use tokio::task::{AbortHandle, JoinHandle, JoinSet}; +use uuid::Uuid; + +use crate::error::{RunSvcError, SvcError, SvcResult}; +use crate::rpc::RpcRequest; +use crate::runservice::StandardService; +use crate::serviceid::{IntoServiceId, ServiceId, ServiceName}; +use crate::template::ServiceTemplate; +use crate::traits::{Service, ServiceRunner, ServiceState}; + +#[derive(Debug)] +pub struct ServiceInstance { + tx: watch::Sender, + name: ServiceName, + state: ServiceState, + abort_handle: AbortHandle, +} + +pub type ServiceFunc = Box< + dyn FnOnce( + Uuid, + watch::Receiver, + mpsc::UnboundedSender, + ) -> BoxFuture<'static, Result<(), RunSvcError>> + + Send, +>; + +#[derive(Debug, Clone, Copy)] +pub struct ServiceEvent { + id: Uuid, + state: ServiceState, +} + +impl ServiceEvent { + #[must_use] + pub const fn new(id: Uuid, state: ServiceState) -> Self { + Self { id, state } + } + + #[must_use] + pub const fn id(&self) -> Uuid { + self.id + } + + #[must_use] + pub const fn state(&self) -> ServiceState { + self.state + } +} + +/// A request to a [`ServiceManager`] +pub enum SvmRequest { + Stop(RpcRequest>), + Start(RpcRequest>), + Status(RpcRequest>), + List(RpcRequest<(), Vec<(Uuid, ServiceName)>>), + Resolve(RpcRequest>), + LookupName(RpcRequest>), + Register(RpcRequest<(String, ServiceFunc), SvcResult>), + RegisterTemplate(RpcRequest<(String, Box), SvcResult<()>>), + Subscribe(RpcRequest, SvcResult>), + Shutdown(RpcRequest<(), ()>), +} + +#[derive(Clone)] +pub struct SvmClient { + tx: mpsc::UnboundedSender, +} + +impl SvmClient { + #[must_use] + pub const fn new(tx: mpsc::UnboundedSender) -> Self { + Self { tx } + } + + pub async fn rpc( + &mut self, + func: impl FnOnce(RpcRequest) -> SvmRequest, + args: Q, + ) -> SvcResult { + let (rpc, rx) = RpcRequest::new(args); + self.send(func(rpc))?; + Ok(rx.await?) + } + + fn send(&self, value: SvmRequest) -> SvcResult<()> { + Ok(self.tx.send(value)?) + } + + pub async fn register_service(&mut self, name: impl AsRef, svc: S) -> SvcResult + where + S: Service + 'static, + { + self.register(&name, StandardService::new(&name, svc)).await + } + + pub async fn register_function( + &mut self, + name: impl AsRef, + func: F, + ) -> SvcResult + where + F: Future> + Send + 'static, + E: Error + Send + 'static, + { + self.register(&name, StandardService::new(&name, Box::pin(func))) + .await + } + + pub async fn register(&mut self, name: impl AsRef, svc: S) -> SvcResult + where + S: ServiceRunner + Send + 'static, + { + let name = name.as_ref().to_string(); + self.rpc( + SvmRequest::Register, + (name, Box::new(|a, b, c| svc.run(a, b, c))), + ) + .await? + } + + pub async fn register_template( + &mut self, + name: impl AsRef, + generator: impl ServiceTemplate + 'static, + ) -> SvcResult<()> { + let name = name.as_ref().to_string(); + self.rpc(SvmRequest::RegisterTemplate, (name, Box::new(generator))) + .await? + } + + pub async fn start(&mut self, id: impl IntoServiceId) -> SvcResult { + self.rpc(SvmRequest::Start, id.service_id()).await? + } + + pub async fn stop(&mut self, id: impl IntoServiceId) -> SvcResult { + self.rpc(SvmRequest::Stop, id.service_id()).await? + } + + pub async fn resolve(&mut self, id: impl IntoServiceId) -> SvcResult { + self.rpc(SvmRequest::Resolve, id.service_id()).await? + } + + pub async fn lookup_name(&mut self, id: impl IntoServiceId) -> SvcResult { + self.rpc(SvmRequest::LookupName, id.service_id()).await? + } + + pub async fn subscribe(&mut self) -> SvcResult<(Uuid, mpsc::UnboundedReceiver)> { + let (tx, rx) = mpsc::unbounded_channel(); + + let uuid = self.rpc(SvmRequest::Subscribe, tx).await??; + + Ok((uuid, rx)) + } + + pub async fn wait_for_state( + &mut self, + handle: impl IntoServiceId, + expected: ServiceState, + ) -> SvcResult<()> { + let svc_id = self.resolve(&handle).await?; + + let (_cid, mut channel) = self.subscribe().await?; + + while let Some(msg) = channel.recv().await { + if msg.id == svc_id { + if msg.state == expected { + return Ok(()); + } + + if msg.state == ServiceState::Failed { + return Err(SvcError::ServiceFailed); + } + } + } + + Err(SvcError::Shutdown) + } + + pub async fn wait_for_start( + &mut self, + handle: impl IntoServiceId + Send + 'static, + ) -> SvcResult<()> { + self.wait_for_state(handle, ServiceState::Running).await + } + + pub async fn wait_for_stop( + &mut self, + handle: impl IntoServiceId + Send + 'static, + ) -> SvcResult<()> { + self.wait_for_state(handle, ServiceState::Stopped).await + } + + pub async fn status( + &mut self, + id: impl IntoServiceId + Send + 'static, + ) -> SvcResult { + self.rpc(SvmRequest::Status, id.service_id()).await? + } + + pub async fn list(&mut self) -> SvcResult> { + self.rpc(SvmRequest::List, ()).await + } + + pub async fn shutdown(&mut self) -> SvcResult<()> { + self.rpc(SvmRequest::Shutdown, ()).await + } +} + +impl Debug for SvmRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Stop(arg0) => f.debug_tuple("Stop").field(arg0).finish(), + Self::Start(arg0) => f.debug_tuple("Start").field(arg0).finish(), + Self::Status(arg0) => f.debug_tuple("Status").field(arg0).finish(), + Self::List(arg0) => f.debug_tuple("List").field(arg0).finish(), + Self::Register(_arg0) => f.debug_tuple("Register").field(&"").finish(), + Self::RegisterTemplate(_arg0) => f + .debug_tuple("RegisterTemplate") + .field(&"") + .finish(), + Self::Resolve(arg0) => f.debug_tuple("Resolve").field(arg0).finish(), + Self::LookupName(arg0) => f.debug_tuple("ResolveName").field(arg0).finish(), + Self::Subscribe(_arg0) => f.debug_tuple("Subscribe").finish(), + Self::Shutdown(_arg0) => f.debug_tuple("Shutdown").finish(), + } + } +} + +pub struct ServiceManager { + control_rx: mpsc::UnboundedReceiver, + control_tx: mpsc::UnboundedSender, + service_rx: mpsc::UnboundedReceiver, + service_tx: mpsc::UnboundedSender, + subscribers: BTreeMap>, + svcs: BTreeMap, + names: BTreeMap, + tasks: JoinSet>, + templates: BTreeMap>, + shutdown: bool, +} + +impl Default for ServiceManager { + fn default() -> Self { + Self::new() + } +} + +impl ServiceManager { + #[must_use] + pub fn new() -> Self { + let (control_tx, control_rx) = mpsc::unbounded_channel(); + let (service_tx, service_rx) = mpsc::unbounded_channel(); + Self { + control_tx, + control_rx, + service_tx, + service_rx, + subscribers: BTreeMap::new(), + svcs: BTreeMap::new(), + names: BTreeMap::new(), + tasks: JoinSet::new(), + templates: BTreeMap::new(), + shutdown: false, + } + } + + /// Daemonize the [`ServiceManager`], returning a (clonable) [`SvmClient`] as + /// well as a [`JoinHandle`] used to control the service manager task + /// itself. + #[must_use] + pub fn daemonize(self) -> (SvmClient, JoinHandle>) { + let client = self.client(); + let fut = tokio::task::spawn(self.run()); + (client, fut) + } + + /// Convenience function to create and daemonize a [`ServiceManager`]. + #[must_use] + pub fn spawn() -> (SvmClient, JoinHandle>) { + Self::new().daemonize() + } + + /// Create a new [`SvmClient`] connected to this service manager. + #[must_use] + pub fn client(&self) -> SvmClient { + SvmClient::new(self.handle()) + } + + fn handle(&self) -> mpsc::UnboundedSender { + self.control_tx.clone() + } + + fn register(&mut self, name: ServiceName, svc: ServiceFunc) -> SvcResult { + if self.names.contains_key(&name) { + return Err(SvcError::ServiceAlreadyExists(name)); + } + + let (tx, rx) = watch::channel(ServiceState::Registered); + let id = Uuid::new_v4(); + + let abort_handle = self.tasks.spawn((svc)(id, rx, self.service_tx.clone())); + + let rec = ServiceInstance { + tx, + name: name.clone(), + state: ServiceState::Registered, + abort_handle, + }; + + self.svcs.insert(id, rec); + self.names.insert(name, id); + + Ok(id) + } + + fn list(&self) -> impl Iterator { + self.svcs.keys() + } + + fn resolve(&self, handle: impl IntoServiceId) -> SvcResult { + let id = handle.service_id(); + match &id { + ServiceId::Name(name) => self + .names + .get(name) + .ok_or_else(|| SvcError::ServiceNotFound(id)) + .copied(), + ServiceId::Id(uuid) => { + if self.svcs.contains_key(uuid) { + Ok(*uuid) + } else { + Err(SvcError::ServiceNotFound(id)) + } + } + } + } + + fn remove(&mut self, handle: &ServiceId) -> SvcResult<()> { + let id = self.resolve(handle)?; + self.svcs.remove(&id); + self.names.retain(|_, v| *v != id); + + Ok(()) + } + + fn abort(&mut self, id: &ServiceId) -> SvcResult<()> { + let svc = self.get(id)?; + + svc.abort_handle.abort(); + + self.remove(id) + } + + fn get(&self, svc: impl IntoServiceId) -> SvcResult<&ServiceInstance> { + let id = self.resolve(svc)?; + Ok(&self.svcs[&id]) + } + + fn start(&mut self, id: impl IntoServiceId) -> SvcResult { + let id = id.service_id(); + + // if the service is known, attempt to start it + if let Ok(svc) = self.get(&id) { + log::debug!("Starting service: {id} {}", &svc.name); + svc.tx.send(ServiceState::Running)?; + return self.resolve(&id); + } + + // ..else, check if it's a named instance + let ServiceId::Name(svc_name) = &id else { + return Err(SvcError::ServiceNotFound(id)); + }; + + let Some(inst) = svc_name.instance() else { + return Err(SvcError::ServiceNotFound(id)); + }; + + let Some(tmpl) = &self.templates.get(svc_name.name()) else { + return Err(SvcError::ServiceNotFound(id)); + }; + + let inner = tmpl.generate(inst.to_string())?; + let svc = StandardService::new(svc_name.name(), inner); + + let uuid = self.register(svc_name.clone(), svc.boxed())?; + + Ok(uuid) + } + + fn stop(&self, id: impl IntoServiceId) -> SvcResult { + let id = self.resolve(id)?; + + if self.svcs[&id].state == ServiceState::Stopped { + return Ok(id); + } + + log::debug!("Stopping service: {id} {}", self.svcs[&id].name); + self.get(id) + .and_then(|svc| Ok(svc.tx.send(ServiceState::Stopped)?))?; + + Ok(id) + } + + fn notify_subscribers(&mut self, event: ServiceEvent) { + let mut failed = vec![]; + for (key, sub) in &self.subscribers { + log::trace!("UPDATE: [sub-{key}] {} -> {:?}", &event.id, &event.state); + if sub.send(event).is_err() { + failed.push(*key); + } + } + if !failed.is_empty() { + self.subscribers.retain(|k, _| !failed.contains(k)); + } + } + + async fn next_event(&mut self) -> SvcResult<()> { + tokio::select! { + event = self.control_rx.recv() => self.handle_svm_request(event.ok_or(SvcError::Shutdown)?).await, + event = self.service_rx.recv() => { + self.handle_service_event(event.ok_or(SvcError::Shutdown)?); + Ok(()) + }, + } + } + + fn handle_service_event(&mut self, event: ServiceEvent) { + self.notify_subscribers(event); + let name = &self.svcs[&event.id].name; + log::trace!("[{name}] [{}] Service is now {:?}", event.id, event.state); + self.svcs.get_mut(&event.id).unwrap().state = event.state; + } + + async fn handle_svm_request(&mut self, upd: SvmRequest) -> SvcResult<()> { + match upd { + SvmRequest::Start(rpc) => rpc.respond(|id| self.start(&id)), + + SvmRequest::Stop(rpc) => rpc.respond(|id| self.stop(&id)), + + SvmRequest::Status(rpc) => rpc.respond(|id| Ok(self.get(&id)?.state)), + + SvmRequest::List(rpc) => rpc.respond(|()| { + let mut res = vec![]; + + for (name, id) in &self.names { + res.push((*id, name.clone())); + } + res + }), + + SvmRequest::Register(rpc) => { + rpc.respond(|(name, svc)| self.register(ServiceName::from(name), svc)); + } + + SvmRequest::RegisterTemplate(rpc) => rpc.respond(|(name, tmpl)| { + self.templates.insert(name, tmpl); + Ok(()) + }), + + SvmRequest::Resolve(rpc) => rpc.respond(|id| self.resolve(&id)), + + SvmRequest::LookupName(rpc) => rpc.respond(|id| Ok(self.get(&id)?.name.clone())), + + SvmRequest::Subscribe(rpc) => { + for (id, svc) in &self.svcs { + rpc.data().send(ServiceEvent::new(*id, svc.state))?; + } + + rpc.respond(|tx| { + let uuid = Uuid::new_v4(); + self.subscribers.insert(uuid, tx); + + Ok(uuid) + }); + } + + SvmRequest::Shutdown(rpc) => { + log::info!("Service manager shutting down.."); + let ids: Vec = self.list().copied().collect(); + + self.stop_multiple(&ids)?; + + select! { + Ok(()) = Box::pin(self.wait_for_multiple(&ids, ServiceState::Stopped)) => {} + () = tokio::time::sleep(Duration::from_secs(3)) => { + log::error!("Service shutdown timed out, aborting tasks.."); + + for id in &ids { + let si = self.get(id)?; + log::error!(" ..aborting {id}: {si:?}"); + self.abort(&ServiceId::from(*id))?; + } + } + } + log::debug!("All services stopped."); + self.shutdown = true; + rpc.respond(|()| ()); + } + } + + Ok(()) + } + + fn stop_multiple(&self, handles: &[impl IntoServiceId]) -> SvcResult<()> { + let ids = self.resolve_multiple(handles)?; + for id in ids { + self.stop(id)?; + } + + Ok(()) + } + + fn resolve_multiple(&self, handles: &[impl IntoServiceId]) -> SvcResult> { + let res = BTreeSet::from_iter( + handles + .iter() + .map(|id| self.resolve(id)) + .collect::, SvcError>>()?, + ); + + Ok(res) + } + + async fn wait_for_multiple( + &mut self, + handles: &[impl IntoServiceId], + target: ServiceState, + ) -> SvcResult<()> { + let mut missing = self.resolve_multiple(handles)?; + let mut done = BTreeSet::new(); + + loop { + for m in &missing { + let state = self.get(*m)?.state; + + if state == ServiceState::Failed && target != ServiceState::Stopped { + return Err(SvcError::ServiceFailed); + } + + if state == target { + done.insert(*m); + } + } + + missing.retain(|f| !done.contains(f)); + + if missing.is_empty() { + return Ok(()); + } + + self.next_event().await?; + } + } + + pub async fn run(mut self) -> SvcResult<()> { + while !self.shutdown { + self.next_event().await?; + } + + Ok(()) + } +} diff --git a/crates/svc/src/policy.rs b/crates/svc/src/policy.rs new file mode 100644 index 0000000..76f72cb --- /dev/null +++ b/crates/svc/src/policy.rs @@ -0,0 +1,71 @@ +//! Implements policies for service behavior (retry count, delay, etc). +use std::time::Duration; + +#[cfg(feature = "manager")] +use tokio::time::sleep; + +#[derive(Debug, Clone, Copy)] +pub enum Retry { + No, + Limit(u32), + Forever, +} + +#[derive(Debug, Clone, Copy)] +pub struct Policy { + pub retry: Retry, + pub delay: Option, +} + +impl Default for Policy { + fn default() -> Self { + Self::new() + } +} + +impl Policy { + #[must_use] + pub const fn new() -> Self { + Self { + retry: Retry::No, + delay: None, + } + } + + #[must_use] + pub const fn with_retry(self, retry: Retry) -> Self { + Self { retry, ..self } + } + + #[must_use] + pub const fn with_delay(self, delay: Duration) -> Self { + Self { + delay: Some(delay), + ..self + } + } + + #[must_use] + pub const fn without_delay(self) -> Self { + Self { + delay: None, + ..self + } + } + + #[cfg(feature = "manager")] + pub async fn sleep(&self) { + if let Some(dur) = self.delay { + sleep(dur).await; + } + } + + #[must_use] + pub const fn should_retry(&self, retry: u32) -> bool { + match self.retry { + Retry::No => false, + Retry::Limit(limit) => retry < limit, + Retry::Forever => true, + } + } +} diff --git a/crates/svc/src/rpc.rs b/crates/svc/src/rpc.rs new file mode 100644 index 0000000..67b85fb --- /dev/null +++ b/crates/svc/src/rpc.rs @@ -0,0 +1,34 @@ +//! Data types for request/response-style communication. +use tokio::sync::oneshot; +use tokio::sync::oneshot::{Receiver, Sender}; + +#[derive(Debug)] +pub struct RpcRequest { + data: Q, + rsp: Sender, +} + +impl RpcRequest { + pub fn new(data: Q) -> (Self, Receiver) { + let (tx, rx) = oneshot::channel(); + let req = Self { data, rsp: tx }; + (req, rx) + } + + pub const fn data(&self) -> &Q { + &self.data + } + + pub fn into_inner(self) -> (Q, Sender) { + (self.data, self.rsp) + } + + pub fn inspect(&mut self, func: impl Fn(&mut Q)) { + func(&mut self.data); + } + + pub fn respond(self, func: impl FnOnce(Q) -> A) { + let res = func(self.data); + let _ = self.rsp.send(res); + } +} diff --git a/crates/svc/src/runservice.rs b/crates/svc/src/runservice.rs new file mode 100644 index 0000000..bf2058f --- /dev/null +++ b/crates/svc/src/runservice.rs @@ -0,0 +1,260 @@ +use async_trait::async_trait; +use std::time::Duration; +use tokio::sync::{mpsc, watch}; +use tokio::time::sleep; +use uuid::Uuid; + +use crate::error::RunSvcError; +use crate::manager::{ServiceEvent, ServiceFunc}; +use crate::policy::{Policy, Retry}; +use crate::traits::{Service, ServiceRunner, ServiceState, StopResult}; + +#[allow(clippy::struct_field_names)] +struct State { + id: Uuid, + retry: u32, + state: ServiceState, + tx: mpsc::UnboundedSender, +} + +impl State { + pub const fn new( + id: Uuid, + state: ServiceState, + tx: mpsc::UnboundedSender, + ) -> Self { + Self { + id, + retry: 0, + state, + tx, + } + } + + pub fn set(&mut self, next: ServiceState) -> Result<(), RunSvcError> { + self.state = next; + self.retry = 0; + Ok(self.tx.send(ServiceEvent::new(self.id, self.state))?) + } + + pub const fn get(&self) -> ServiceState { + self.state + } + + pub const fn retry(&mut self) -> u32 { + let res = self.retry; + self.retry += 1; + res + } +} + +pub struct StandardService { + name: String, + svc: S, + configure_policy: Policy, + start_policy: Policy, + run_policy: Policy, + stop_policy: Policy, +} + +impl StandardService { + pub fn new(name: impl AsRef, svc: S) -> Self { + Self { + name: name.as_ref().to_string(), + svc, + configure_policy: Policy::new(), + start_policy: Policy::new() + .with_delay(Duration::from_secs(1)) + .with_retry(Retry::Limit(3)), + run_policy: Policy::new().with_delay(Duration::from_secs(1)), + stop_policy: Policy::new(), + } + } + + #[allow(clippy::missing_const_for_fn)] + pub fn name(&self) -> &str { + &self.name + } + + #[must_use] + pub const fn with_configure_policy(mut self, policy: Policy) -> Self { + self.configure_policy = policy; + self + } + + #[must_use] + pub const fn with_start_policy(mut self, policy: Policy) -> Self { + self.start_policy = policy; + self + } + + #[must_use] + pub const fn with_run_policy(mut self, policy: Policy) -> Self { + self.run_policy = policy; + self + } + + #[must_use] + pub const fn with_stop_policy(mut self, policy: Policy) -> Self { + self.stop_policy = policy; + self + } +} + +impl StandardService { + pub fn boxed(self) -> ServiceFunc { + Box::new(|a, b, c| self.run(a, b, c)) + } +} + +#[allow(clippy::too_many_lines)] +#[async_trait] +impl ServiceRunner for StandardService { + async fn run( + mut self, + id: Uuid, + mut rx: watch::Receiver, + tx: mpsc::UnboundedSender, + ) -> Result<(), RunSvcError> { + let name = self.name; + let target = &format!("[{name}]"); + let mut svc = self.svc; + + log::trace!(target:target, "Registered"); + svc.configure() + .await + .map_err(|e| RunSvcError::ServiceError(Box::new(e)))?; + + let mut state = State::new(id, ServiceState::Registered, tx); + + loop { + match state.get() { + ServiceState::Registered => { + if *rx.borrow() == ServiceState::Running { + match svc.configure().await { + Ok(()) => { + log::trace!(target:target, "Configured"); + state.set(ServiceState::Configured)?; + } + Err(err) => { + log::error!(target:target, "Failed to configure service: {err}"); + sleep(Duration::from_secs(3)).await; + } + } + } else { + rx.changed().await?; + } + } + + ServiceState::Configured => { + log::trace!(target:target, "Service configured, and is ready start."); + if *rx.borrow_and_update() == ServiceState::Running { + state.set(ServiceState::Starting)?; + } else { + rx.changed().await?; + } + } + + ServiceState::Starting => match svc.start().await { + Ok(()) => { + log::debug!(target:target, "Started"); + state.set(ServiceState::Running)?; + } + Err(err) => { + log::error!(target:target, "Failed to start service: {err}"); + if *rx.borrow() == ServiceState::Stopped { + state.set(ServiceState::Stopped)?; + } else { + sleep(Duration::from_secs(3)).await; + } + } + }, + + ServiceState::Running => { + tokio::select! { + res = svc.run() => match res { + Ok(()) => { + log::debug!(target:target, "Service completed successfully"); + state.set(ServiceState::Stopping)?; + } + Err(err) => { + self.run_policy.sleep().await; + if self.run_policy.should_retry(state.retry()) { + log::warn!(target:target, "Service failed to start, retrying.."); + } else { + log::error!(target:target, "Failed to run service: {err}"); + match svc.stop().await { + Ok(()) => { + log::debug!(target:target, "Stopped failing service"); + } + Err(err) => { + log::error!( + "Failed to stop failing service: {err}" + ); + } + } + state.set(ServiceState::Failed)?; + } + } + }, + _ = rx.changed() => if *rx.borrow() == ServiceState::Stopped { + log::trace!(target:target, "Stopping service"); + let stop = svc.signal_stop().await.map_err(|e| RunSvcError::ServiceError(Box::new(e)))?; + match stop { + StopResult::Delivered => { + log::trace!(target:target, "Service state change requested (graceful)"); + tokio::select! { + res = svc.run() => { + log::trace!(target:target, "Service finished running within timeout: {res:?}"); + }, + () = sleep(Duration::from_secs(1)) => { + log::warn!("timeout"); + } + } + state.set(ServiceState::Stopping)?; + } + StopResult::NotSupported => { + log::trace!(target:target, "Service state change requested: {:?} -> {:?}", state.get(), *rx.borrow()); + if *rx.borrow_and_update() == ServiceState::Stopped { + state.set(ServiceState::Stopping)?; + } + } + } + } + } + } + + ServiceState::Stopping => match svc.stop().await { + Ok(()) => { + log::trace!(target:target, "Stopping"); + state.set(ServiceState::Stopped)?; + } + Err(err) => { + log::error!(target:target, "Failed to stop service: {err}"); + sleep(Duration::from_secs(3)).await; + } + }, + + ServiceState::Stopped => { + rx.changed().await?; + if rx.has_changed()? { + log::debug!(target:target, "Service stopped."); + } + if *rx.borrow_and_update() == ServiceState::Running { + state.set(ServiceState::Starting)?; + } + } + + ServiceState::Failed => { + rx.changed().await?; + if rx.has_changed()? { + log::debug!(target:target, "Service failed."); + } + if *rx.borrow() == ServiceState::Stopped { + state.set(ServiceState::Stopped)?; + } + } + } + } + } +} diff --git a/crates/svc/src/serviceid.rs b/crates/svc/src/serviceid.rs new file mode 100644 index 0000000..0efe9fc --- /dev/null +++ b/crates/svc/src/serviceid.rs @@ -0,0 +1,153 @@ +use std::fmt::{Debug, Display}; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(from = "String", into = "String")] +pub struct ServiceName { + name: String, + instance: Option, +} + +impl From for String { + fn from(value: ServiceName) -> Self { + value.to_string() + } +} + +impl ServiceName { + #[must_use] + pub const fn new(name: String, instance: Option) -> Self { + Self { name, instance } + } + + // suppress clippy false-positive + #[allow(clippy::missing_const_for_fn)] + #[must_use] + pub fn name(&self) -> &str { + &self.name + } + + #[must_use] + pub fn instance(&self) -> Option<&str> { + self.instance.as_deref() + } +} + +impl Display for ServiceName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self { + name, + instance: None, + } => write!(f, "{name}"), + Self { + name, + instance: Some(instance), + } => write!(f, "{name}@{instance}"), + } + } +} + +#[derive(Debug, Clone)] +pub enum ServiceId { + Name(ServiceName), + Id(Uuid), +} + +impl ServiceId { + pub fn instance(name: impl Into, instance: impl Into) -> Self { + Self::Name(ServiceName { + name: name.into(), + instance: Some(instance.into()), + }) + } +} + +impl Display for ServiceId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Name(name) => write!(f, "{name}"), + Self::Id(uuid) => write!(f, "{uuid}"), + } + } +} + +pub trait IntoServiceId: Display + Debug + Clone { + fn service_id(self) -> ServiceId; +} + +impl IntoServiceId for ServiceId { + fn service_id(self) -> ServiceId { + self + } +} + +impl IntoServiceId for &I { + fn service_id(self) -> ServiceId { + self.clone().service_id() + } +} + +impl IntoServiceId for Uuid { + fn service_id(self) -> ServiceId { + ServiceId::Id(self) + } +} + +impl IntoServiceId for String { + fn service_id(self) -> ServiceId { + ServiceId::Name(ServiceName::from(self)) + } +} + +impl IntoServiceId for &str { + fn service_id(self) -> ServiceId { + ServiceId::Name(ServiceName::from(self)) + } +} + +impl From for ServiceId { + fn from(value: Uuid) -> Self { + Self::Id(value) + } +} + +impl From for ServiceId { + fn from(value: String) -> Self { + value.service_id() + } +} + +impl From for ServiceName { + fn from(value: String) -> Self { + if let Some((name, instance)) = value.split_once('@') { + Self { + name: name.to_string(), + instance: Some(instance.to_string()), + } + } else { + Self { + name: value, + instance: None, + } + } + } +} + +impl From<&str> for ServiceName { + fn from(value: &str) -> Self { + if let Some((name, instance)) = value.split_once('@') { + Self { + name: name.to_string(), + instance: Some(instance.to_string()), + } + } else { + Self { + name: value.to_string(), + instance: None, + } + } + } +} diff --git a/crates/svc/src/template.rs b/crates/svc/src/template.rs new file mode 100644 index 0000000..04393b9 --- /dev/null +++ b/crates/svc/src/template.rs @@ -0,0 +1,70 @@ +use async_trait::async_trait; + +#[cfg(feature = "manager")] +use crate::error::RunSvcError; +use crate::error::SvcError; +use crate::traits::{BoxDynService, Service, StopResult}; + +#[cfg(feature = "manager")] +pub trait ServiceTemplate: Send { + fn generate(&self, instance: String) -> Result; +} + +pub struct ErrorAdapter { + svc: S, +} + +impl ErrorAdapter { + pub const fn new(svc: S) -> Self { + Self { svc } + } +} + +#[async_trait] +impl Service for ErrorAdapter { + type Error = RunSvcError; + + async fn configure(&mut self) -> Result<(), Self::Error> { + self.svc + .configure() + .await + .map_err(|err| RunSvcError::ServiceError(Box::new(err))) + } + + async fn start(&mut self) -> Result<(), Self::Error> { + self.svc + .start() + .await + .map_err(|err| RunSvcError::ServiceError(Box::new(err))) + } + + async fn run(&mut self) -> Result<(), Self::Error> { + self.svc + .run() + .await + .map_err(|err| RunSvcError::ServiceError(Box::new(err))) + } + + async fn stop(&mut self) -> Result<(), Self::Error> { + self.svc + .stop() + .await + .map_err(|err| RunSvcError::ServiceError(Box::new(err))) + } + + async fn signal_stop(&mut self) -> Result { + self.svc + .signal_stop() + .await + .map_err(|err| RunSvcError::ServiceError(Box::new(err))) + } +} + +impl ServiceTemplate for F +where + F: Fn(String) -> Result + Send, +{ + fn generate(&self, instance: String) -> Result { + self(instance) + } +} diff --git a/crates/svc/src/traits.rs b/crates/svc/src/traits.rs new file mode 100644 index 0000000..fa4ca58 --- /dev/null +++ b/crates/svc/src/traits.rs @@ -0,0 +1,162 @@ +use std::error::Error; + +use async_trait::async_trait; +use futures::future::BoxFuture; +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "manager")] +use crate::error::RunSvcError; +#[cfg(feature = "manager")] +use crate::manager::ServiceEvent; +#[cfg(feature = "manager")] +use crate::template::ErrorAdapter; +#[cfg(feature = "manager")] +use std::future::Future; +#[cfg(feature = "manager")] +use tokio::sync::{mpsc, watch}; +#[cfg(feature = "manager")] +use uuid::Uuid; + +/** +State of a [`Service`] running on a [`crate::manager::ServiceManager`]. + +Transition diagram for [`ServiceState`]: + +```text + ┌────────────────┐ + │ Registered ├──┐ + │ │ │ + └───────────┬────┘ │ + ┌───────────▼────┐ │ + │ Configured │ │ + │ │ │ + └───────────┬────┘ │ + ┌───────────▼────┐ │ + ┌─►│ Starting ├──┤ + │ │ │ │ + │ └───────────┬────┘ │ + │ ┌───────────▼────┐ │ + │ │ Running ├──┤ + │ │ │ │ + │ └───────────┬────┘ │ + │ ┌───────────▼────┐ │ + │ │ Stopping ├──┤ + │ │ │ │ + │ └───────────┬────┘ │ + │ ┌───────────▼────┐ │ + └──┤ Stopped │ │ + ┌─►│ │ │ + │ └────────────────┘ │ + │ ┌────────────────┐ │ + │ │ Failed │ │ + └──┤ │◄─┘ + └────────────────┘ +``` +*/ + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ServiceState { + /// Service is registered with the service manager, but not configured yet + Registered, + /// Service is registered, and has finished one-time setup in preparation for running + Configured, + /// Service is in the starting phase. If successfull, it will then be in [`ServiceState::Running`]. + Starting, + /// Service is running normally + Running, + /// Servic is in the shutdown phase. If successfull, it will then be in [`ServiceState::Stopped`]. + Stopping, + /// Service is not running, but is ready to start up again + Stopped, + /// Service has failed + Failed, +} + +pub enum StopResult { + Delivered, + NotSupported, +} + +#[async_trait] +pub trait Service: Send { + type Error: Error + Send + 'static; + + async fn configure(&mut self) -> Result<(), Self::Error> { + Ok(()) + } + + async fn start(&mut self) -> Result<(), Self::Error> { + Ok(()) + } + + async fn run(&mut self) -> Result<(), Self::Error>; + + async fn stop(&mut self) -> Result<(), Self::Error> { + Ok(()) + } + + async fn signal_stop(&mut self) -> Result { + Ok(StopResult::NotSupported) + } + + #[cfg(feature = "manager")] + fn boxed(self) -> BoxDynService + where + Self: Sized + Unpin + 'static, + { + Box::new(ErrorAdapter::new(self)) as BoxDynService + } +} + +#[cfg(feature = "manager")] +pub type BoxDynService = Box + Unpin + 'static>; + +#[cfg(feature = "manager")] +impl Service for BoxDynService { + type Error = RunSvcError; + + fn run<'a: 'b, 'b>(&'a mut self) -> BoxFuture<'b, Result<(), Self::Error>> { + (**self).run() + } + + fn configure<'a: 'b, 'b>(&'a mut self) -> BoxFuture<'b, Result<(), Self::Error>> { + (**self).configure() + } + + fn start<'a: 'b, 'b>(&'a mut self) -> BoxFuture<'b, Result<(), Self::Error>> { + (**self).start() + } + + fn stop<'a: 'b, 'b>(&'a mut self) -> BoxFuture<'b, Result<(), Self::Error>> { + (**self).stop() + } + + fn signal_stop<'a: 'b, 'b>(&'a mut self) -> BoxFuture<'b, Result> { + (**self).signal_stop() + } +} + +#[cfg(feature = "manager")] +#[async_trait] +pub trait ServiceRunner { + async fn run( + mut self, + id: Uuid, + rx: watch::Receiver, + tx: mpsc::UnboundedSender, + ) -> Result<(), RunSvcError>; +} + +#[cfg(feature = "manager")] +#[async_trait] +impl Service for F +where + E: Error + Send + 'static, + F: Future> + Send + Unpin, +{ + type Error = E; + + async fn run(&mut self) -> Result<(), E> { + self.await + } +} diff --git a/crates/z2m/Cargo.toml b/crates/z2m/Cargo.toml new file mode 100644 index 0000000..3826ded --- /dev/null +++ b/crates/z2m/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "z2m" +version = "0.1.0" + +edition.workspace = true +authors.workspace = true +rust-version.workspace = true +description.workspace = true +readme.workspace = true +repository.workspace = true +license.workspace = true +categories.workspace = true +keywords.workspace = true + +[lints] +workspace = true + +[dependencies] +hue = { version = "0.1.0", path = "../hue", default-features = false } +serde = "1.0.219" +serde_json = "1.0.140" +thiserror = "2.0.12" diff --git a/crates/z2m/src/api.rs b/crates/z2m/src/api.rs new file mode 100644 index 0000000..8818f56 --- /dev/null +++ b/crates/z2m/src/api.rs @@ -0,0 +1,730 @@ +#![allow(clippy::struct_excessive_bools)] + +use std::collections::HashMap; +use std::fmt::Debug; +use std::fmt::Display; + +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::Value; + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(deny_unknown_fields)] +pub struct RawMessage { + pub topic: String, + pub payload: Value, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(tag = "topic", content = "payload")] +pub enum Message { + #[serde(rename = "bridge/info")] + BridgeInfo(Box), + + #[serde(rename = "bridge/state")] + BridgeState(Value), + + #[serde(rename = "bridge/event")] + BridgeEvent(BridgeEvent), + + #[serde(rename = "bridge/devices")] + BridgeDevices(BridgeDevices), + + #[serde(rename = "bridge/groups")] + BridgeGroups(BridgeGroups), + + #[serde(rename = "bridge/logging")] + BridgeLogging(BridgeLogging), + + #[serde(rename = "bridge/definitions")] + BridgeDefinitions(Value), + + #[serde(rename = "bridge/extensions")] + BridgeExtensions(Value), + + #[serde(rename = "bridge/converters")] + BridgeConverters(Value), + + #[serde(rename = "bridge/response/options")] + BridgeOptions(Value), + + #[serde(rename = "bridge/response/touchlink/scan")] + BridgeTouchlinkScan(Value), + + #[serde(rename = "bridge/response/permit_join")] + BridgePermitJoin(Value), + + #[serde(rename = "bridge/response/networkmap")] + BridgeNetworkmap(Value), + + #[serde(rename = "bridge/config")] + BridgeConfig(Value), + + #[serde(rename = "bridge/response/group/add")] + BridgeResponseGroupAdd(Response), + + #[serde(rename = "bridge/response/group/remove")] + BridgeResponseGroupRemove(Response), + + #[serde(rename = "bridge/response/group/rename")] + BridgeResponseGroupRename(Response), + + #[serde(rename = "bridge/response/group/options")] + BridgeResponseGroupOptions(Response), + + #[serde(rename = "bridge/response/group/members/add")] + BridgeGroupMembersAdd(Response), + + #[serde(rename = "bridge/response/group/members/remove")] + BridgeGroupMembersRemove(Response), + + #[serde(rename = "bridge/response/device/remove")] + BridgeDeviceRemove(Response), + + #[serde(rename = "bridge/response/device/options")] + BridgeDeviceOptions(Value), + + #[serde(rename = "bridge/response/device/configure_reporting")] + BridgeDeviceConfigureReporting(Value), + + #[serde(rename = "bridge/response/device/ota_update/check")] + BridgeDeviceOtaUpdateCheck(Value), +} + +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +pub enum Endpoint { + #[default] + Default, + #[serde(untagged)] + Name(String), + #[serde(untagged)] + Number(u32), +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct GroupMemberChange { + pub device: String, + pub group: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub endpoint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub skip_disable_reporting: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct PermitJoin { + pub time: u32, + pub device: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct DeviceRemove { + pub id: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct DeviceRemoveResponse { + pub id: String, + pub block: bool, + pub force: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "lowercase")] +pub enum Response { + Ok { + data: T, + #[serde(default)] + transaction: Option, + }, + Error { + error: Value, + #[serde(default)] + transaction: Option, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GroupAdd { + pub id: Option, + pub friendly_name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GroupRemove { + pub id: String, + #[serde(default)] + pub force: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GroupMemberAddRemove { + pub device: String, + pub endpoint: u8, + pub group: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GroupRename { + pub from: String, + pub to: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GroupOptions { + pub from: Value, + pub to: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeviceRename { + pub from: String, + pub to: String, + #[serde(default)] + pub homeassistant_rename: bool, +} + +#[derive(Serialize, Deserialize, Clone, Hash, Debug, Copy)] +#[serde(rename_all = "snake_case")] +pub enum Availability { + Online, + Offline, +} + +#[derive(Serialize, Deserialize, Clone, Hash)] +#[serde(transparent)] +pub struct IeeeAddress(#[serde(deserialize_with = "ieee_address")] u64); + +impl Debug for IeeeAddress { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "IeeeAddress({:016x})", self.0) + } +} + +impl Display for IeeeAddress { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "0x{:016x}", self.0) + } +} + +fn ieee_address<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + use serde::de::Error; + let s: &str = Deserialize::deserialize(deserializer)?; + let num = u64::from_str_radix(s.trim_start_matches("0x"), 16).map_err(Error::custom)?; + Ok(num) +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "lowercase")] +pub enum BridgeOnlineState { + Online, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct BridgeState { + pub state: BridgeOnlineState, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct BridgeEvent { + /* FIXME: needs proper mapping */ + /* See: /lib/extension/bridge.ts */ + pub data: Value, + #[serde(rename = "type")] + pub event_type: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct BridgeLogging { + pub level: String, + pub message: String, + pub topic: Option, +} + +type BridgeGroups = Vec; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Group { + pub friendly_name: String, + #[serde(default)] + pub description: Option, + pub id: u32, + pub members: Vec, + pub scenes: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct GroupMember { + pub endpoint: u32, + pub ieee_address: IeeeAddress, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct EndpointLink { + pub endpoint: u32, + pub ieee_address: IeeeAddress, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct GroupLink { + pub id: u32, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Scene { + pub id: u32, + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BridgeInfo { + pub commit: String, + pub config: Config, + pub config_schema: BridgeConfigSchema, + pub coordinator: Coordinator, + pub log_level: String, + pub network: Network, + pub permit_join: bool, + pub restart_required: bool, + pub version: String, + pub zigbee_herdsman: Version, + pub zigbee_herdsman_converters: Version, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BridgeConfigSchema { + pub definitions: Value, + #[serde(default)] + pub required: Vec, + pub properties: Value, + #[serde(rename = "type")] + pub config_type: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + pub advanced: ConfigAdvanced, + #[serde(default)] + pub availability: Value, + #[serde(default)] + pub version: Value, + pub blocklist: Vec>, + pub device_options: Value, + pub devices: HashMap, + #[serde(default)] + pub external_converters: Vec>, + pub frontend: Value, + pub groups: HashMap, + #[serde(with = "crate::serde_util::struct_or_false")] + pub homeassistant: Option, + pub map_options: Value, + pub mqtt: Value, + pub ota: Value, + pub passlist: Vec>, + pub serial: ConfigSerial, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Version { + pub version: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Network { + pub channel: i64, + pub extended_pan_id: Value, + pub pan_id: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Coordinator { + pub ieee_address: IeeeAddress, + /* stict parsing disabled for now, format too volatile between versions */ + /* pub meta: CoordinatorMeta, */ + pub meta: Value, + #[serde(rename = "type")] + pub coordinator_type: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConfigAdvanced { + pub adapter_concurrent: Option, + pub adapter_delay: Option, + pub cache_state: bool, + pub cache_state_persistent: bool, + pub cache_state_send_on_startup: bool, + pub channel: i64, + pub elapsed: bool, + pub ext_pan_id: Vec, + pub homeassistant_legacy_entity_attributes: Option, + pub last_seen: String, + pub log_debug_namespace_ignore: String, + pub log_debug_to_mqtt_frontend: bool, + pub log_directory: String, + pub log_file: String, + pub log_level: String, + pub log_namespaced_levels: Value, + pub log_output: Vec, + pub log_rotation: bool, + pub log_symlink_current: bool, + pub log_syslog: Value, + pub output: String, + pub pan_id: i64, + pub timestamp_format: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CoordinatorMeta { + pub build: i64, + pub ezsp: i64, + pub major: i64, + pub minor: i64, + pub patch: i64, + pub revision: String, + pub special: i64, + #[serde(rename = "type")] + pub meta_type: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConfigSerial { + pub adapter: Option, + pub disable_led: bool, + #[serde(default)] + pub port: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConfigHomeassistant { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default)] + pub experimental_event_entities: Option, + #[serde(default)] + pub legacy_action_sensor: Option, + pub discovery_topic: String, + pub status_topic: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GroupValue { + #[serde(default)] + pub devices: Vec, + pub friendly_name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub enum PowerSource { + #[serde(rename = "Unknown")] + #[default] + Unknown = 0, + #[serde(rename = "Mains (single phase)")] + MainsSinglePhase = 1, + #[serde(rename = "Mains (3 phase)")] + MainsThreePhase = 2, + #[serde(rename = "Battery")] + Battery = 3, + #[serde(rename = "DC Source")] + DcSource = 4, + #[serde(rename = "Emergency mains constantly powered")] + EmergencyMainsConstantly = 5, + #[serde(rename = "Emergency mains and transfer switch")] + EmergencyMainsAndTransferSwitch = 6, +} + +pub type BridgeDevices = Vec; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum DeviceType { + Coordinator, + Router, + EndDevice, + Unknown, + GreenPower, +} + +#[allow(clippy::pub_underscore_fields)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Device { + pub description: Option, + pub date_code: Option, + pub definition: Option, + pub disabled: bool, + pub endpoints: HashMap, + pub friendly_name: String, + pub ieee_address: IeeeAddress, + pub interview_completed: bool, + pub interviewing: bool, + pub manufacturer: Option, + pub model_id: Option, + pub network_address: u16, + #[serde(default)] + pub power_source: PowerSource, + pub software_build_id: Option, + pub supported: Option, + #[serde(rename = "type")] + pub device_type: DeviceType, + + /* all other fields */ + #[serde(skip_serializing_if = "HashMap::is_empty")] + #[serde(default, flatten)] + pub __: HashMap, +} + +impl Device { + #[must_use] + pub fn exposes(&self) -> &[Expose] { + self.definition.as_ref().map_or(&[], |def| &def.exposes) + } + + #[must_use] + pub fn expose_light(&self) -> Option<&ExposeLight> { + self.exposes().iter().find_map(|exp| { + if let Expose::Light(light) = exp { + Some(light) + } else { + None + } + }) + } + + #[must_use] + pub fn expose_gradient(&self) -> Option<&ExposeList> { + self.exposes().iter().find_map(|exp| { + if let Expose::List(grad) = exp { + if grad + .base + .property + .as_ref() + .is_some_and(|prop| prop == "gradient") + { + Some(grad) + } else { + None + } + } else { + None + } + }) + } + + #[must_use] + pub fn expose_action(&self) -> bool { + self.exposes().iter().any(|exp| { + if let Expose::Enum(ExposeEnum { base, .. }) = exp { + base.name.as_deref() == Some("action") + } else { + false + } + }) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeviceDefinition { + pub model: String, + pub vendor: String, + pub description: String, + pub exposes: Vec, + pub supports_ota: bool, + pub options: Vec, + #[serde(default)] + pub icon: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum Expose { + Binary(ExposeBinary), + Composite(ExposeComposite), + Enum(ExposeEnum), + Light(ExposeLight), + Lock(ExposeLock), + Numeric(ExposeNumeric), + Switch(ExposeSwitch), + List(ExposeList), + + /* FIXME: Not modelled yet */ + Text(ExposeGeneric), + Cover(ExposeGeneric), + Fan(ExposeGeneric), + Climate(ExposeGeneric), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExposeGeneric { + #[serde(flatten)] + pub base: ExposeBase, + #[serde(flatten)] + pub other: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ExposeCategory { + Config, + Diagnostic, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExposeBase { + pub name: Option, + pub label: Option, + #[serde(default)] + pub access: u8, + pub endpoint: Option, + pub property: Option, + pub description: Option, + #[serde(default)] + pub features: Vec, + pub category: Option, +} + +impl Expose { + #[must_use] + pub const fn base(&self) -> &ExposeBase { + #[allow(clippy::match_same_arms)] + match self { + Self::Binary(exp) => &exp.base, + Self::Composite(exp) => &exp.base, + Self::Enum(exp) => &exp.base, + Self::Light(exp) => &exp.base, + Self::List(exp) => &exp.base, + Self::Lock(exp) => &exp.base, + Self::Numeric(exp) => &exp.base, + Self::Switch(exp) => &exp.base, + Self::Text(exp) => &exp.base, + Self::Cover(exp) => &exp.base, + Self::Fan(exp) => &exp.base, + Self::Climate(exp) => &exp.base, + } + } + + #[must_use] + pub fn name(&self) -> Option<&str> { + self.base().name.as_deref() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExposeBinary { + #[serde(flatten)] + pub base: ExposeBase, + pub value_off: Value, + pub value_on: Value, + pub value_toggle: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExposeComposite { + #[serde(flatten)] + pub base: ExposeBase, + // FIXME +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExposeEnum { + #[serde(flatten)] + pub base: ExposeBase, + pub values: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExposeLight { + #[serde(flatten)] + pub base: ExposeBase, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExposeLock { + #[serde(flatten)] + pub base: ExposeBase, +} + +impl ExposeLight { + #[must_use] + pub fn feature(&self, name: &str) -> Option<&Expose> { + self.base + .features + .iter() + .find(|exp| exp.name() == Some(name)) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExposeList { + #[serde(flatten)] + pub base: ExposeBase, + pub item_type: Box, + #[serde(default)] + pub length_min: Option, + #[serde(default)] + pub length_max: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExposeNumeric { + #[serde(flatten)] + pub base: ExposeBase, + + pub unit: Option, + pub value_max: Option, + pub value_min: Option, + pub value_step: Option, + + #[serde(default)] + pub presets: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExposeSwitch { + #[serde(flatten)] + pub base: ExposeBase, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeviceEndpoint { + pub bindings: Vec, + pub configured_reportings: Vec, + pub clusters: DeviceEndpointClusters, + pub scenes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeviceEndpointConfiguredReporting { + pub attribute: Value, + pub cluster: String, + pub maximum_report_interval: i64, + pub minimum_report_interval: i64, + #[serde(default)] + pub reportable_change: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Preset { + pub description: String, + pub name: String, + pub value: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeviceEndpointBinding { + pub cluster: String, + pub target: DeviceEndpointBindingTarget, +} + +// NOTE: definition diverges from z2m, but is more strict +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum DeviceEndpointBindingTarget { + Group(GroupLink), + Endpoint(EndpointLink), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeviceEndpointClusters { + pub input: Vec, + pub output: Vec, +} diff --git a/crates/z2m/src/convert.rs b/crates/z2m/src/convert.rs new file mode 100644 index 0000000..daf245f --- /dev/null +++ b/crates/z2m/src/convert.rs @@ -0,0 +1,199 @@ +use std::collections::BTreeSet; + +use hue::api::{ + ColorGamut, ColorTemperature, DeviceProductData, Dimming, GamutType, GroupedLightUpdate, + LightColor, LightGradient, LightGradientMode, LightGradientPoint, LightGradientUpdate, + LightUpdate, MirekSchema, +}; +use hue::devicedb::{hardware_platform_type, product_archetype}; +use hue::xy::XY; + +use crate::api::{Device, Expose, ExposeList, ExposeNumeric}; +use crate::update::{DeviceColorMode, DeviceUpdate}; + +pub trait ExtractExposeNumeric { + fn extract_mirek_schema(&self) -> Option; +} + +impl ExtractExposeNumeric for ExposeNumeric { + #[must_use] + #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] + fn extract_mirek_schema(&self) -> Option { + if self.unit.as_deref() == Some("mired") { + if let (Some(min), Some(max)) = (self.value_min, self.value_max) { + return Some(MirekSchema { + mirek_minimum: min as u32, + mirek_maximum: max as u32, + }); + } + } + None + } +} + +pub trait ExtractLightColor { + #[must_use] + fn extract_from_expose(expose: &Expose) -> Option + where + Self: Sized; +} + +impl ExtractLightColor for LightColor { + fn extract_from_expose(expose: &Expose) -> Option { + let Expose::Composite(_) = expose else { + return None; + }; + + Some(Self { + gamut: Some(ColorGamut::GAMUT_C), + gamut_type: GamutType::C, + xy: XY::D65_WHITE_POINT, + }) + } +} + +pub trait ExtractLightGradient { + #[must_use] + fn extract_from_expose(expose: &ExposeList) -> Option + where + Self: Sized; +} + +impl ExtractLightGradient for LightGradient { + #[must_use] + fn extract_from_expose(expose: &ExposeList) -> Option { + match expose { + ExposeList { + length_max: Some(max), + .. + } => Some(Self { + mode: LightGradientMode::InterpolatedPalette, + mode_values: BTreeSet::from([ + LightGradientMode::InterpolatedPalette, + LightGradientMode::InterpolatedPaletteMirrored, + LightGradientMode::RandomPixelated, + ]), + points_capable: *max.min(&5), + points: vec![], + pixel_count: *max.min(&7), + }), + _ => None, + } + } +} + +pub trait ExtractColorTemperature: Sized { + #[must_use] + fn extract_from_expose(expose: &Expose) -> Option; +} + +impl ExtractColorTemperature for ColorTemperature { + #[must_use] + fn extract_from_expose(expose: &Expose) -> Option { + let Expose::Numeric(num) = expose else { + return None; + }; + + let schema_opt = num.extract_mirek_schema(); + let mirek_valid = schema_opt.is_some(); + let mirek_schema = schema_opt.unwrap_or(MirekSchema::DEFAULT); + let mirek = None; + + Some(Self { + mirek, + mirek_schema, + mirek_valid, + }) + } +} + +pub trait ExtractDimming: Sized { + #[must_use] + fn extract_from_expose(expose: &Expose) -> Option; +} + +impl ExtractDimming for Dimming { + #[must_use] + fn extract_from_expose(expose: &Expose) -> Option { + let Expose::Numeric(_) = expose else { + return None; + }; + + Some(Self { + brightness: 0.01, + min_dim_level: Some(0.01), + }) + } +} + +pub trait ExtractDeviceProductData { + #[must_use] + fn guess_from_device(dev: &Device) -> Self; +} + +impl ExtractDeviceProductData for DeviceProductData { + #[must_use] + fn guess_from_device(dev: &Device) -> Self { + fn str_or_unknown(name: Option<&String>) -> String { + name.map_or("", |v| v).to_string() + } + + let product_name = str_or_unknown(dev.definition.as_ref().map(|def| &def.model)); + let model_id = str_or_unknown(dev.model_id.as_ref()); + let manufacturer_name = str_or_unknown(dev.manufacturer.as_ref()); + let certified = manufacturer_name == Self::SIGNIFY_MANUFACTURER_NAME; + let software_version = str_or_unknown(dev.software_build_id.as_ref()); + + let product_archetype = product_archetype(&model_id).unwrap_or_default(); + let hardware_platform_type = hardware_platform_type(&model_id).map(ToString::to_string); + + Self { + model_id, + manufacturer_name, + product_name, + product_archetype, + certified, + software_version, + hardware_platform_type, + } + } +} + +impl From<&DeviceUpdate> for LightUpdate { + fn from(value: &DeviceUpdate) -> Self { + let mut upd = Self::new() + .with_on(value.state.map(Into::into)) + .with_brightness(value.brightness.map(|b| b / 254.0 * 100.0)) + .with_color_temperature(value.color_temp) + .with_gradient(value.gradient.as_ref().map(|s| { + LightGradientUpdate { + mode: None, + points: s + .iter() + .map(|hc| LightGradientPoint::xy(hc.to_xy_color())) + .collect(), + } + })); + + if value.color_mode != Some(DeviceColorMode::ColorTemp) { + upd = upd.with_color_xy(value.color.and_then(|col| col.xy)); + } + + upd + } +} + +impl From<&GroupedLightUpdate> for DeviceUpdate { + fn from(upd: &GroupedLightUpdate) -> Self { + Self::default() + .with_state(upd.on.map(|on| on.on)) + .with_brightness(upd.dimming.map(|dim| dim.brightness / 100.0 * 254.0)) + .with_color_temp(upd.color_temperature.and_then(|ct| ct.mirek)) + .with_color_xy(upd.color.map(|col| col.xy)) + .with_transition( + upd.dynamics + .as_ref() + .and_then(|d| d.duration.map(|duration| f64::from(duration) / 1000.0)), + ) + } +} diff --git a/crates/z2m/src/error.rs b/crates/z2m/src/error.rs new file mode 100644 index 0000000..ca9b562 --- /dev/null +++ b/crates/z2m/src/error.rs @@ -0,0 +1,25 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum Z2mError { + /* mapped errors */ + #[error(transparent)] + FromUtf8Error(#[from] std::string::FromUtf8Error), + + #[error(transparent)] + ParseIntError(#[from] std::num::ParseIntError), + + #[error(transparent)] + IOError(#[from] std::io::Error), + + #[error(transparent)] + SerdeJson(#[from] serde_json::Error), + + #[error(transparent)] + HueError(#[from] hue::error::HueError), + + #[error("Invalid hex color")] + InvalidHexColor, +} + +pub type Z2mResult = Result; diff --git a/crates/z2m/src/hexcolor.rs b/crates/z2m/src/hexcolor.rs new file mode 100644 index 0000000..17a36a7 --- /dev/null +++ b/crates/z2m/src/hexcolor.rs @@ -0,0 +1,107 @@ +use std::fmt::Display; + +use serde::{Deserialize, Serialize}; + +use hue::xy::XY; + +use crate::error::Z2mError; + +#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)] +#[serde(into = "String", try_from = "&str")] +pub struct HexColor { + pub r: u8, + pub g: u8, + pub b: u8, +} + +impl HexColor { + #[must_use] + pub const fn new(r: u8, g: u8, b: u8) -> Self { + Self { r, g, b } + } + + #[must_use] + pub fn to_xy_color(&self) -> XY { + XY::from_rgb(self.r, self.g, self.b).0 + } + + #[must_use] + pub fn from_xy_color(xy: XY, brightness: f64) -> Self { + let rgb = xy.to_rgb(brightness); + Self::new(rgb[0], rgb[1], rgb[2]) + } +} + +impl From<[u8; 3]> for HexColor { + fn from([r, g, b]: [u8; 3]) -> Self { + Self::new(r, g, b) + } +} + +impl From for String { + fn from(value: HexColor) -> Self { + format!("{value}") + } +} + +impl Display for HexColor { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "#{:02x}{:02x}{:02x}", self.r, self.g, self.b) + } +} + +impl TryFrom<&str> for HexColor { + type Error = Z2mError; + + fn try_from(value: &str) -> Result { + if value.len() != 7 || !value.starts_with('#') { + return Err(Z2mError::InvalidHexColor); + } + let r = u8::from_str_radix(&value[1..3], 16)?; + let g = u8::from_str_radix(&value[3..5], 16)?; + let b = u8::from_str_radix(&value[5..7], 16)?; + Ok(Self { r, g, b }) + } +} + +#[cfg(test)] +mod tests { + use crate::hexcolor::HexColor; + + #[test] + fn make_hexcolor() { + let h = HexColor::new(0, 0, 0); + assert_eq!(h.to_string(), "#000000"); + + let h = HexColor::new(255, 255, 255); + assert_eq!(h.to_string(), "#ffffff"); + + let h = HexColor::new(255, 0, 0); + assert_eq!(h.to_string(), "#ff0000"); + + let h = HexColor::new(0, 255, 0); + assert_eq!(h.to_string(), "#00ff00"); + + let h = HexColor::new(0, 0, 255); + assert_eq!(h.to_string(), "#0000ff"); + + let h = HexColor::new(128, 192, 255); + assert_eq!(h.to_string(), "#80c0ff"); + } + + #[test] + fn parse_hexcolor() { + assert_eq!( + HexColor::try_from(HexColor::new(0, 1, 2).to_string().as_str()).unwrap(), + HexColor::new(0, 1, 2) + ); + assert_eq!( + HexColor::try_from(HexColor::new(192, 199, 255).to_string().as_str()).unwrap(), + HexColor::new(192, 199, 255) + ); + assert_eq!( + HexColor::try_from(HexColor::new(255, 255, 255).to_string().as_str()).unwrap(), + HexColor::new(255, 255, 255) + ); + } +} diff --git a/crates/z2m/src/lib.rs b/crates/z2m/src/lib.rs new file mode 100644 index 0000000..8c41c4f --- /dev/null +++ b/crates/z2m/src/lib.rs @@ -0,0 +1,7 @@ +pub mod api; +pub mod convert; +pub mod error; +pub mod hexcolor; +pub mod request; +pub mod serde_util; +pub mod update; diff --git a/crates/z2m/src/request.rs b/crates/z2m/src/request.rs new file mode 100644 index 0000000..def0bfd --- /dev/null +++ b/crates/z2m/src/request.rs @@ -0,0 +1,57 @@ +use serde::Serialize; +use serde_json::Value; + +use crate::api::{DeviceRemove, GroupMemberChange, PermitJoin}; +use crate::update::DeviceUpdate; + +#[derive(Clone, Debug, Serialize)] +pub struct Z2mPayload { + pub data: Vec, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Z2mRequest<'a> { + SceneStore { + name: &'a str, + #[serde(rename = "ID")] + id: u32, + }, + + SceneRecall(u32), + + SceneRemove(u32), + + Write { + cluster: u16, + payload: Value, + }, + + Command { + cluster: u16, + command: u16, + payload: Z2mPayload, + }, + + #[serde(untagged)] + GroupMemberAdd(GroupMemberChange), + + #[serde(untagged)] + GroupMemberRemove(GroupMemberChange), + + #[serde(untagged)] + PermitJoin(PermitJoin), + + #[serde(untagged)] + DeviceRemove(DeviceRemove), + + #[serde(untagged)] + Update(&'a DeviceUpdate), + + // same as Z2mRequest::Raw, but allows us to suppress logging for these + #[serde(untagged)] + EntertainmentFrame(Value), + + #[serde(untagged)] + Raw(Value), +} diff --git a/crates/z2m/src/serde_util.rs b/crates/z2m/src/serde_util.rs new file mode 100644 index 0000000..381f442 --- /dev/null +++ b/crates/z2m/src/serde_util.rs @@ -0,0 +1,130 @@ +use std::any::type_name; +use std::fmt; +use std::marker::PhantomData; + +use serde::de::{Deserialize, Deserializer, Unexpected}; +use serde::{Serialize, Serializer, de}; + +pub fn deserialize_struct_or_false<'de, T, D>(d: D) -> Result, D::Error> +where + T: Deserialize<'de>, + D: Deserializer<'de>, +{ + // Internal wrapper struct + struct StructOrFalse(PhantomData); + + impl<'de, T> de::Visitor<'de> for StructOrFalse + where + T: Deserialize<'de>, + { + type Value = Option; + + fn visit_bool(self, value: bool) -> Result + where + E: de::Error, + { + /* false means `None`, true is unexpected */ + if value { + Err(de::Error::invalid_type(Unexpected::Bool(value), &self)) + } else { + Ok(None) + } + } + + fn visit_map(self, visitor: M) -> Result + where + M: de::MapAccess<'de>, + { + let mvd = de::value::MapAccessDeserializer::new(visitor); + Deserialize::deserialize(mvd).map(Some) + } + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "false or {}", type_name::()) + } + } + + d.deserialize_any(StructOrFalse(PhantomData)) +} + +pub fn serialize_struct_or_false(v: &Option, serializer: S) -> Result +where + T: Serialize, + S: Serializer, +{ + match v { + None => false.serialize(serializer), + Some(d) => d.serialize(serializer), + } +} + +pub mod struct_or_false { + pub use super::deserialize_struct_or_false as deserialize; + pub use super::serialize_struct_or_false as serialize; +} + +#[cfg(test)] +mod tests { + use serde::{Deserialize, Serialize}; + use serde_json::{from_str, to_string}; + + use crate::error::Z2mResult; + + #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] + struct Foo { + #[serde(with = "super::struct_or_false")] + foo: Option, + } + + #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] + struct Bar { + bar: u32, + } + + const FOO_NONE: Foo = Foo { foo: None }; + const FOO_SOME: Foo = Foo { + foo: Some(Bar { bar: 42 }), + }; + + const FOO_NONE_STR: &str = r#"{"foo":false}"#; + const FOO_SOME_STR: &str = r#"{"foo":{"bar":42}}"#; + const FOO_TRUE: &str = r#"{"foo":true}"#; + const FOO_LIST: &str = r#"{"foo":[42]}"#; + + #[test] + pub fn serialize_none() -> Z2mResult<()> { + assert_eq!(to_string(&FOO_NONE)?, FOO_NONE_STR); + Ok(()) + } + + #[test] + pub fn serialize_some() -> Z2mResult<()> { + assert_eq!(to_string(&FOO_SOME)?, FOO_SOME_STR); + + Ok(()) + } + + #[test] + pub fn deserialize_false() -> Z2mResult<()> { + assert_eq!(from_str::(FOO_NONE_STR)?, FOO_NONE); + Ok(()) + } + + #[test] + pub fn deserialize_struct() -> Z2mResult<()> { + assert_eq!(from_str::(FOO_SOME_STR)?, FOO_SOME); + Ok(()) + } + + #[test] + pub fn deserialize_true() { + /* must return error */ + from_str::(FOO_TRUE).unwrap_err(); + } + + #[test] + pub fn deserialize_list() { + /* must return error */ + from_str::(FOO_LIST).unwrap_err(); + } +} diff --git a/crates/z2m/src/update.rs b/crates/z2m/src/update.rs new file mode 100644 index 0000000..58d08ba --- /dev/null +++ b/crates/z2m/src/update.rs @@ -0,0 +1,257 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use hue::api::{LightGradientUpdate, On}; +use hue::xy::XY; + +use crate::hexcolor::HexColor; + +#[allow(clippy::pub_underscore_fields)] +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct DeviceUpdate { + #[serde(skip_serializing_if = "Option::is_none")] + pub state: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub brightness: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub color_temp: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub color_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub gradient: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub linkquality: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub color_options: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub color_temp_startup: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub level_config: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub elapsed: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub power_on_behavior: Option, + #[serde(skip_serializing_if = "HashMap::is_empty")] + #[serde(default)] + pub update: HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub update_available: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub battery: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub transition: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub effect: Option, + + /* all other fields */ + #[serde(skip_serializing_if = "HashMap::is_empty")] + #[serde(default, flatten)] + pub __: HashMap, +} + +impl DeviceUpdate { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub fn with_state(self, state: Option) -> Self { + Self { + state: state.map(|on| { + if on { + DeviceState::On + } else { + DeviceState::Off + } + }), + ..self + } + } + + #[must_use] + pub fn with_brightness(self, brightness: Option) -> Self { + Self { + brightness: brightness.map(|b| b.clamp(1.0, 254.0)), + ..self + } + } + + #[must_use] + pub fn with_color_temp(self, mirek: Option) -> Self { + Self { + color_temp: mirek, + ..self + } + } + + #[must_use] + pub fn with_color_xy(self, xy: Option) -> Self { + Self { + color: xy.map(DeviceColor::xy), + ..self + } + } + + #[must_use] + pub fn with_gradient(self, grad: Option) -> Self { + Self { + gradient: grad.map(|g| { + g.points + .iter() + .map(|p| { + let [r, g, b] = p.color.xy.to_rgb(255.0); + HexColor::new(r, g, b) + }) + .collect() + }), + ..self + } + } + + #[must_use] + pub fn with_effect(self, effect: DeviceEffect) -> Self { + Self { + effect: Some(effect), + ..self + } + } + + #[must_use] + pub fn with_transition(self, transition: Option) -> Self { + Self { transition, ..self } + } +} + +#[derive(Copy, Debug, Serialize, Deserialize, Clone)] +#[serde(deny_unknown_fields)] +pub struct DeviceColor { + #[allow(dead_code)] + #[serde(skip_serializing)] + h: Option, + #[allow(dead_code)] + #[serde(skip_serializing)] + s: Option, + + pub hue: Option, + pub saturation: Option, + + #[serde(flatten)] + pub xy: Option, +} + +impl DeviceColor { + #[must_use] + pub const fn xy(xy: XY) -> Self { + Self { + h: None, + s: None, + hue: None, + saturation: None, + xy: Some(xy), + } + } + + #[must_use] + pub const fn hs(h: f64, s: f64) -> Self { + Self { + h: None, + s: None, + hue: Some(h), + saturation: Some(s), + xy: None, + } + } +} + +#[derive(Copy, Debug, Serialize, Deserialize, Clone, Default)] +#[serde(deny_unknown_fields)] +pub enum PowerOnBehavior { + #[default] + Unknown, + + #[serde(rename = "on")] + On, + + #[serde(rename = "off")] + Off, + + #[serde(rename = "previous")] + Previous, +} + +#[derive(Copy, Debug, Serialize, Deserialize, Clone)] +#[serde(deny_unknown_fields)] +pub struct ColorOptions { + pub execute_if_off: bool, +} + +#[derive(Copy, Debug, Serialize, Deserialize, Clone)] +#[serde(deny_unknown_fields)] +pub struct LevelConfig { + pub execute_if_off: Option, + pub on_off_transition_time: Option, + pub on_transition_time: Option, + pub off_transition_time: Option, + pub current_level_startup: Option, + pub on_level: Option, +} + +#[derive(Copy, Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "lowercase")] +pub enum CurrentLevelStartup { + Previous, + Minimum, + #[serde(untagged)] + Value(u8), +} + +#[derive(Copy, Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "lowercase")] +pub enum OnLevel { + Previous, + #[serde(untagged)] + Value(u8), +} + +#[derive(Copy, Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum DeviceColorMode { + ColorTemp, + Hs, + Xy, +} + +#[derive(Copy, Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "UPPERCASE")] +pub enum DeviceState { + On, + Off, + Lock, + Unlock, +} + +impl From for On { + fn from(value: DeviceState) -> Self { + Self { + on: value == DeviceState::On, + } + } +} + +#[derive(Copy, Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum DeviceEffect { + Blink, + Breathe, + Okay, + ChannelChange, + FinishEffect, + StopEffect, +} diff --git a/crates/zcl/Cargo.toml b/crates/zcl/Cargo.toml new file mode 100644 index 0000000..347ed38 --- /dev/null +++ b/crates/zcl/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "zcl" +version = "0.1.0" + +edition.workspace = true +authors.workspace = true +rust-version.workspace = true +description.workspace = true +readme.workspace = true +repository.workspace = true +license.workspace = true +categories.workspace = true +keywords.workspace = true + +[lints] +workspace = true + +[dependencies] +byteorder = "1.5.0" +hex = "0.4.3" +hue = { version = "0.1.0", path = "../hue" } +packed_struct = "0.10.1" +thiserror = "2.0.11" diff --git a/crates/zcl/src/attr.rs b/crates/zcl/src/attr.rs new file mode 100644 index 0000000..3476f11 --- /dev/null +++ b/crates/zcl/src/attr.rs @@ -0,0 +1,351 @@ +use std::io::Read; +use std::{fmt::Debug, io::Cursor}; + +use byteorder::{LE, ReadBytesExt}; +use packed_struct::prelude::*; + +use crate::error::{ZclError, ZclResult}; + +#[derive(PrimitiveEnum_u8, Debug, Clone, Copy, Eq, PartialEq)] +pub enum ZclProfileCommand { + ReadAttribute = 0x00, + ReadAttributeRsp = 0x01, + WriteAttribute = 0x02, + WriteAttributeRsp = 0x03, +} + +#[derive(PrimitiveEnum_u8, Debug, Clone, Copy, Eq, PartialEq)] +pub enum ZclCommand { + ReadAttrib = 0x00, + ReadAttribResp = 0x01, + WriteAttrib = 0x02, + WriteAttribUndiv = 0x03, + WriteAttribResp = 0x04, + WriteAttribNoResp = 0x05, + ConfigReport = 0x06, + ConfigReportResp = 0x07, + ReadReportCfg = 0x08, + ReadReportCfgResp = 0x09, + ReportAttrib = 0x0a, + DefaultResp = 0x0b, + DiscAttrib = 0x0c, + DiscAttribResp = 0x0d, + ReadAttribStruct = 0x0e, + WriteAttribStruct = 0x0f, + WriteAttribStructResp = 0x10, + DiscoverCommandsReceived = 0x11, + DiscoverCommandsReceivedRes = 0x12, + DiscoverCommandsGenerated = 0x13, + DiscoverCommandsGeneratedRes = 0x14, + DiscoverAttrExt = 0x15, + DiscoverAttrExtRes = 0x16, +} + +#[derive(PrimitiveEnum_u8, Debug, Clone, Copy, Eq, PartialEq)] +pub enum ZclDataType { + /** Null data type */ + Null = 0x00, + + /** 8-bit value data type */ + Zcl8bit = 0x08, + + /** 16-bit value data type */ + Zcl16bit = 0x09, + + /** 32-bit value data type */ + Zcl32bit = 0x0b, + + /** Boolean data type */ + ZclBool = 0x10, + + /** 8-bit bitmap data type */ + Zcl8bitmap = 0x18, + + /** 16-bit bitmap data type */ + Zcl16bitmap = 0x19, + + /** 32-bit bitmap data type */ + Zcl32bitmap = 0x1b, + + /** 40-bit bitmap data type */ + Zcl40bitmap = 0x1c, + + /** 48-bit bitmap data type */ + Zcl48bitmap = 0x1d, + + /** 56-bit bitmap data type */ + Zcl56bitmap = 0x1e, + + /** 64-bit bitmap data type */ + Zcl64bitmap = 0x1f, + + /** Unsigned 8-bit value data type */ + ZclU8 = 0x20, + + /** Unsigned 16-bit value data type */ + ZclU16 = 0x21, + + /** Unsigned 32-bit value data type */ + ZclU32 = 0x23, + + /** Unsigned 16-bit value data type */ + ZclI16 = 0x29, + + /** Unsigned 8-bit value data type */ + ZclE8 = 0x30, + + /** Byte array data type */ + ZclBytearray = 0x41, + + /** Charactery string (array) data type */ + ZclCharstring = 0x42, + + /** IEEE address (U64) type */ + ZclIeeeaddr = 0xf0, + + /** 128-bit security key */ + ZclSecurityKey = 0xf1, + + /** Invalid data type */ + ZclInvalid = 0xff, +} + +#[derive(Debug, Clone)] +pub struct ZclReadAttr { + pub attr: Vec, +} + +impl ZclReadAttr { + pub fn parse(data: &[u8]) -> ZclResult { + if data.len() % 2 != 0 { + return Err(ZclError::PackedStructError(PackingError::InvalidValue)); + } + + let mut attr = vec![]; + + data.chunks(2) + .for_each(|v| attr.push(u16::from_le_bytes([v[0], v[1]]))); + + Ok(Self { attr }) + } +} + +#[derive(Clone)] +pub enum ZclAttrValue { + Null, + X8(i8), + X16(i16), + X32(i32), + Bool(bool), + B8(u8), + B16(u16), + B32(u32), + B40(u64), + B48(u64), + B56(u64), + B64(u64), + U8(u8), + U16(u16), + U32(u32), + I16(i16), + E8(u8), + Bytes(Vec), + String(String), + IeeeAddr(Vec), + SecurityKey([u8; 16]), + Unsupported, +} + +impl Debug for ZclAttrValue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Null => write!(f, "Null"), + Self::X8(val) => write!(f, "x8:{val}"), + Self::X16(val) => write!(f, "x16:{val}"), + Self::X32(val) => write!(f, "x32:{val}"), + Self::Bool(val) => write!(f, "bool:{val}"), + Self::B8(val) => write!(f, "b8:{val:02X}"), + Self::B16(val) => write!(f, "b16:{val:04X}"), + Self::B32(val) => write!(f, "b32:{val:08X}"), + Self::B40(val) => write!(f, "b40:{val:010X}"), + Self::B48(val) => write!(f, "b48:{val:012X}"), + Self::B56(val) => write!(f, "b56:{val:014X}"), + Self::B64(val) => write!(f, "b64:{val:016X}"), + Self::U8(val) => write!(f, "u8:{val:02X}"), + Self::U16(val) => write!(f, "u16:{val:04X}"), + Self::U32(val) => write!(f, "u32:{val:08X}"), + Self::I16(val) => write!(f, "i16:{val:04X}"), + Self::E8(val) => write!(f, "e8:{val:02X}"), + Self::Bytes(val) => write!(f, "hex:{}", hex::encode(val)), + Self::String(val) => write!(f, "str:{val}"), + Self::IeeeAddr(val) => write!(f, "ieeeaddr {}", hex::encode(val)), + Self::SecurityKey(val) => write!(f, "seckey {}", hex::encode(val)), + Self::Unsupported => write!(f, "Unsupported"), + } + } +} + +impl Debug for ZclAttr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:04x}:{:?}", self.key, self.value) + } +} + +#[derive(Clone)] +pub struct ZclAttr { + pub key: u16, + pub value: ZclAttrValue, +} + +impl ZclAttr { + fn from_reader(rdr: &mut impl Read, check_status: bool) -> ZclResult { + let key = rdr.read_u16::()?; + + if check_status { + let status = rdr.read_u8()?; + if status != 0 { + return Ok(Self { + key, + value: ZclAttrValue::Unsupported, + }); + } + } + + let zdt = rdr.read_u8()?; + let dtype = ZclDataType::from_primitive(zdt).ok_or(ZclError::UnsupportedAttrType(zdt))?; + + let value = match dtype { + ZclDataType::Null => ZclAttrValue::Null, + ZclDataType::Zcl8bit => ZclAttrValue::X8(rdr.read_i8()?), + ZclDataType::Zcl16bit => ZclAttrValue::X16(rdr.read_i16::()?), + ZclDataType::Zcl32bit => ZclAttrValue::X32(rdr.read_i32::()?), + ZclDataType::ZclBool => ZclAttrValue::Bool(rdr.read_u8()? != 0), + ZclDataType::Zcl8bitmap => ZclAttrValue::B8(rdr.read_u8()?), + ZclDataType::Zcl16bitmap => ZclAttrValue::B16(rdr.read_u16::()?), + ZclDataType::Zcl32bitmap => ZclAttrValue::B32(rdr.read_u32::()?), + ZclDataType::Zcl40bitmap => todo!(), + ZclDataType::Zcl48bitmap => todo!(), + ZclDataType::Zcl56bitmap => todo!(), + ZclDataType::Zcl64bitmap => ZclAttrValue::B64(rdr.read_u64::()?), + ZclDataType::ZclU8 => ZclAttrValue::U8(rdr.read_u8()?), + ZclDataType::ZclU16 => ZclAttrValue::U16(rdr.read_u16::()?), + ZclDataType::ZclU32 => ZclAttrValue::U32(rdr.read_u32::()?), + ZclDataType::ZclI16 => ZclAttrValue::I16(rdr.read_i16::()?), + ZclDataType::ZclE8 => ZclAttrValue::E8(rdr.read_u8()?), + ZclDataType::ZclBytearray => { + let len = rdr.read_u8()?; + let mut buf = vec![0; len as usize]; + rdr.read_exact(&mut buf)?; + ZclAttrValue::Bytes(buf) + } + ZclDataType::ZclCharstring => { + let len = rdr.read_u8()?; + let mut buf = vec![0; len as usize]; + rdr.read_exact(&mut buf)?; + ZclAttrValue::String(String::from_utf8(buf)?) + } + ZclDataType::ZclIeeeaddr => todo!(), + ZclDataType::ZclSecurityKey => { + let mut buf = [0; 16]; + rdr.read_exact(&mut buf)?; + ZclAttrValue::SecurityKey(buf) + } + ZclDataType::ZclInvalid => todo!(), + }; + + Ok(Self { key, value }) + } + + pub fn readattr_from_reader(rdr: &mut impl Read) -> ZclResult { + Self::from_reader(rdr, true) + } + + pub fn writeattr_from_reader(rdr: &mut impl Read) -> ZclResult { + Self::from_reader(rdr, false) + } +} + +#[derive(Debug, Clone)] +pub struct ZclReadAttrResp { + pub attr: Vec, +} + +impl ZclReadAttrResp { + #[allow(clippy::cast_possible_truncation)] + pub fn parse(data: &[u8]) -> ZclResult { + let mut attr = vec![]; + + let mut cur = Cursor::new(data); + while (cur.position() as usize) < data.len() { + attr.push(ZclAttr::readattr_from_reader(&mut cur)?); + } + + Ok(Self { attr }) + } +} + +#[derive(Debug, Clone)] +pub struct ZclWriteAttr { + pub attr: Vec, +} + +impl ZclWriteAttr { + #[allow(clippy::cast_possible_truncation)] + pub fn parse(data: &[u8]) -> ZclResult { + let mut attr = vec![]; + + let mut cur = Cursor::new(data); + while (cur.position() as usize) < data.len() { + attr.push(ZclAttr::writeattr_from_reader(&mut cur)?); + } + + Ok(Self { attr }) + } +} + +#[derive(Debug, Clone)] +pub struct ZclReportAttr { + pub attr: Vec, +} + +impl ZclReportAttr { + #[allow(clippy::cast_possible_truncation)] + pub fn parse(data: &[u8]) -> ZclResult { + let mut attr = vec![]; + + let mut cur = Cursor::new(data); + while (cur.position() as usize) < data.len() { + attr.push(ZclAttr::writeattr_from_reader(&mut cur)?); + } + + Ok(Self { attr }) + } +} + +#[derive(Debug, Clone)] +pub struct ZclDefaultResp { + pub cmd: u8, + pub stat: u8, +} + +impl ZclDefaultResp { + pub const fn parse(data: &[u8]) -> ZclResult { + Ok(Self { + cmd: data[0], + stat: data[1], + }) + } +} + +#[derive(Debug, Clone)] +pub struct ZclWriteAttrResp { + pub attr: Vec, +} + +impl ZclWriteAttrResp { + pub fn parse(data: &[u8]) -> ZclResult { + Ok(Self { + attr: data.to_vec(), + }) + } +} diff --git a/crates/zcl/src/cluster/colorctrl.rs b/crates/zcl/src/cluster/colorctrl.rs new file mode 100644 index 0000000..c18155c --- /dev/null +++ b/crates/zcl/src/cluster/colorctrl.rs @@ -0,0 +1,35 @@ +use crate::frame::{ZclFrame, ZclFrameDirection}; + +#[must_use] +pub fn describe(frame: &ZclFrame, _data: &[u8]) -> Option { + if frame.manufacturer_specific() { + return None; + } + + if frame.flags.direction != ZclFrameDirection::ClientToServer { + return None; + } + + match frame.cmd { + 0x00 => Some("MoveToHue".to_string()), + 0x01 => Some("MoveHue".to_string()), + 0x02 => Some("StepHue".to_string()), + 0x03 => Some("MoveToSaturation".to_string()), + 0x04 => Some("MoveSaturation".to_string()), + 0x05 => Some("StepSaturation".to_string()), + 0x06 => Some("MoveToHueAndSaturation".to_string()), + 0x07 => Some("MoveToColor".to_string()), + 0x08 => Some("MoveColor".to_string()), + 0x09 => Some("StepColor".to_string()), + 0x0a => Some("MoveToColorTemp".to_string()), + 0x40 => Some("EnhancedMoveToHue".to_string()), + 0x41 => Some("EnhancedMoveHue".to_string()), + 0x42 => Some("EnhancedStepHue".to_string()), + 0x43 => Some("EnhancedMoveToHueAndSaturation".to_string()), + 0x44 => Some("ColorLoopSet".to_string()), + 0x47 => Some("StopMoveStep".to_string()), + 0x4b => Some("MoveColorTemp".to_string()), + 0x4c => Some("StepColorTemp".to_string()), + _ => None, + } +} diff --git a/crates/zcl/src/cluster/commissioning.rs b/crates/zcl/src/cluster/commissioning.rs new file mode 100644 index 0000000..a484c73 --- /dev/null +++ b/crates/zcl/src/cluster/commissioning.rs @@ -0,0 +1,20 @@ +use crate::error::ZclResult; +use crate::frame::ZclFrame; +use hue::zigbee::HueEntFrame; + +pub fn describe(frame: &ZclFrame, data: &[u8]) -> ZclResult> { + if !frame.cluster_specific() { + return Ok(None); + } + + match frame.cmd { + 0x00 => Ok(Some("ScanRequest".to_string())), + 0x02 => { + let (data, csum) = data.split_at(data.len() - 4); + let csum = u32::from_be_bytes([csum[0], csum[1], csum[2], csum[3]]); + let hes = HueEntFrame::parse(data)?; + Ok(Some(format!("{hes:x?} [PROXY, {csum:08x}]"))) + } + _ => Ok(None), + } +} diff --git a/crates/zcl/src/cluster/effects.rs b/crates/zcl/src/cluster/effects.rs new file mode 100644 index 0000000..e1df3f7 --- /dev/null +++ b/crates/zcl/src/cluster/effects.rs @@ -0,0 +1,13 @@ +use crate::frame::{ZclFrame, ZclFrameDirection}; + +#[must_use] +pub fn describe(frame: &ZclFrame, _data: &[u8]) -> Option { + if frame.flags.direction == ZclFrameDirection::ClientToServer { + match frame.cmd { + 0x40 => Some("Trigger".to_string()), + _ => None, + } + } else { + None + } +} diff --git a/crates/zcl/src/cluster/groups.rs b/crates/zcl/src/cluster/groups.rs new file mode 100644 index 0000000..b847afe --- /dev/null +++ b/crates/zcl/src/cluster/groups.rs @@ -0,0 +1,18 @@ +use crate::frame::{ZclFrame, ZclFrameDirection}; + +#[must_use] +pub fn describe(frame: &ZclFrame, _data: &[u8]) -> Option { + if frame.flags.direction == ZclFrameDirection::ClientToServer { + match frame.cmd { + 0x00 => Some("Add".to_string()), + 0x02 => Some("GetMembership".to_string()), + _ => None, + } + } else { + match frame.cmd { + 0x00 => Some("AddResp".to_string()), + 0x02 => Some("GetMembershipResp".to_string()), + _ => None, + } + } +} diff --git a/crates/zcl/src/cluster/hue_fc01.rs b/crates/zcl/src/cluster/hue_fc01.rs new file mode 100644 index 0000000..682e435 --- /dev/null +++ b/crates/zcl/src/cluster/hue_fc01.rs @@ -0,0 +1,26 @@ +use packed_struct::PackedStructSlice; + +use crate::error::ZclResult; +use crate::frame::ZclFrame; +use hue::zigbee::{HueEntFrame, HueEntSegmentConfig, HueEntSegmentLayout, HueEntStop}; + +pub fn describe(frame: &ZclFrame, data: &[u8]) -> ZclResult> { + if !frame.cluster_specific() { + return Ok(None); + } + + match frame.cmd { + 1 => Ok(Some(format!("{:x?}", HueEntFrame::parse(data)?))), + 3 => Ok(Some(format!("{:x?}", HueEntStop::unpack_from_slice(data)?))), + 4 => { + let res = if frame.c2s() && data.len() == 1 { + "HueEntSegmentLayoutReq".to_string() + } else { + format!("{:x?}", HueEntSegmentLayout::parse(data)?) + }; + Ok(Some(res)) + } + 7 => Ok(Some(format!("{:x?}", HueEntSegmentConfig::parse(data)?))), + _ => Ok(None), + } +} diff --git a/crates/zcl/src/cluster/hue_fc03.rs b/crates/zcl/src/cluster/hue_fc03.rs new file mode 100644 index 0000000..3230c06 --- /dev/null +++ b/crates/zcl/src/cluster/hue_fc03.rs @@ -0,0 +1,18 @@ +use hue::zigbee::Flags; + +use crate::error::ZclResult; +use crate::frame::ZclFrame; + +pub fn describe(frame: &ZclFrame, data: &[u8]) -> ZclResult> { + if !frame.cluster_specific() { + return Ok(None); + } + + match frame.cmd { + 0x00 => { + let zflags = Flags::from_bits(u16::from(data[0]) | (u16::from(data[1]) << 8)).unwrap(); + Ok(Some(format!("{:?} {}", zflags, hex::encode(&data[2..])))) + } + _ => Ok(None), + } +} diff --git a/crates/zcl/src/cluster/levelctrl.rs b/crates/zcl/src/cluster/levelctrl.rs new file mode 100644 index 0000000..eb99af5 --- /dev/null +++ b/crates/zcl/src/cluster/levelctrl.rs @@ -0,0 +1,24 @@ +use crate::frame::{ZclFrame, ZclFrameDirection}; + +#[must_use] +pub fn describe(frame: &ZclFrame, _data: &[u8]) -> Option { + if frame.manufacturer_specific() { + return None; + } + + if frame.flags.direction != ZclFrameDirection::ClientToServer { + return None; + } + + match frame.cmd { + 0x00 => Some("MoveToLevel".to_string()), + 0x01 => Some("Move".to_string()), + 0x02 => Some("Step".to_string()), + 0x03 => Some("Stop".to_string()), + 0x04 => Some("MoveToLevelWithOnOff".to_string()), + 0x05 => Some("MoveWithOnOff".to_string()), + 0x06 => Some("StepWithOnOff".to_string()), + 0x07 => Some("StopWithOnOff".to_string()), + _ => None, + } +} diff --git a/crates/zcl/src/cluster/mod.rs b/crates/zcl/src/cluster/mod.rs new file mode 100644 index 0000000..108035b --- /dev/null +++ b/crates/zcl/src/cluster/mod.rs @@ -0,0 +1,10 @@ +pub mod colorctrl; +pub mod commissioning; +pub mod effects; +pub mod groups; +pub mod hue_fc01; +pub mod hue_fc03; +pub mod levelctrl; +pub mod onoff; +pub mod scenes; +pub mod standard; diff --git a/crates/zcl/src/cluster/onoff.rs b/crates/zcl/src/cluster/onoff.rs new file mode 100644 index 0000000..cfb35e6 --- /dev/null +++ b/crates/zcl/src/cluster/onoff.rs @@ -0,0 +1,19 @@ +use crate::frame::{ZclFrame, ZclFrameDirection}; + +#[must_use] +pub fn describe(frame: &ZclFrame, _data: &[u8]) -> Option { + if frame.manufacturer_specific() { + return None; + } + + if frame.flags.direction != ZclFrameDirection::ClientToServer { + return None; + } + + match frame.cmd { + 0x00 => Some("Off".to_string()), + 0x01 => Some("On".to_string()), + 0x40 => Some("OffWithEffect".to_string()), + _ => None, + } +} diff --git a/crates/zcl/src/cluster/scenes.rs b/crates/zcl/src/cluster/scenes.rs new file mode 100644 index 0000000..a994fed --- /dev/null +++ b/crates/zcl/src/cluster/scenes.rs @@ -0,0 +1,39 @@ +#![allow(clippy::collapsible_else_if)] + +use hue::zigbee::Flags; + +use crate::frame::{ZclFrame, ZclFrameDirection}; + +#[must_use] +pub fn describe(frame: &ZclFrame, data: &[u8]) -> Option { + if frame.manufacturer_specific() { + if frame.flags.direction == ZclFrameDirection::ClientToServer { + match frame.cmd { + 0x02 => Some(format!( + "SetComposite {:?}", + Flags::from_bits(u16::from(data[3]) | (u16::from(data[4]) << 8)).unwrap() + )), + _ => None, + } + } else { + match frame.cmd { + 0x02 => Some("SetCompositeOk".to_string()), + _ => None, + } + } + } else { + if frame.flags.direction == ZclFrameDirection::ClientToServer { + match frame.cmd { + 0x02 => Some("Remove".to_string()), + 0x05 => Some("Recall".to_string()), + 0x06 => Some("GetMembership".to_string()), + _ => None, + } + } else { + match frame.cmd { + 0x06 => Some("GetMembershipResp".to_string()), + _ => None, + } + } + } +} diff --git a/crates/zcl/src/cluster/standard.rs b/crates/zcl/src/cluster/standard.rs new file mode 100644 index 0000000..49403a3 --- /dev/null +++ b/crates/zcl/src/cluster/standard.rs @@ -0,0 +1,41 @@ +use packed_struct::PrimitiveEnum; + +use crate::attr::{ + ZclCommand, ZclReadAttr, ZclReadAttrResp, ZclReportAttr, ZclWriteAttr, ZclWriteAttrResp, +}; +use crate::error::ZclResult; +use crate::frame::ZclFrame; + +pub fn describe(frame: &ZclFrame, data: &[u8]) -> ZclResult> { + let cmd = ZclCommand::from_primitive(frame.cmd); + let desc = match cmd { + Some(ZclCommand::ReadAttrib) => { + let req = ZclReadAttr::parse(data)?; + Some(format!("Attr rd -> {:04x?}", req.attr)) + } + Some(ZclCommand::ReadAttribResp) => { + let req = ZclReadAttrResp::parse(data)?; + Some(format!("Attr rd <- {:?}", req.attr)) + } + Some(ZclCommand::WriteAttrib) => { + let req = ZclWriteAttr::parse(data)?; + Some(format!("Attr wr -> {:?}", req.attr)) + } + Some(ZclCommand::WriteAttribResp) => { + let req = ZclWriteAttrResp::parse(data)?; + Some(format!("Attr wr <- {:02x?}", req.attr)) + } + Some(ZclCommand::ReportAttrib) => { + let req = ZclReportAttr::parse(data)?; + Some(format!("Attr rp <- {:02x?}", req.attr)) + } + Some(ZclCommand::DefaultResp) => { + /* let req = ZclDefaultResp::parse(data)?; */ + /* format!("Attr dr <- {:02x} {:02x}", req.cmd, req.stat) */ + return Ok(Some(String::new())); + } + _ => None, + }; + + Ok(desc) +} diff --git a/crates/zcl/src/error.rs b/crates/zcl/src/error.rs new file mode 100644 index 0000000..42e5cdb --- /dev/null +++ b/crates/zcl/src/error.rs @@ -0,0 +1,22 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ZclError { + /* mapped errors */ + #[error(transparent)] + FromUtf8Error(#[from] std::string::FromUtf8Error), + + #[error(transparent)] + IOError(#[from] std::io::Error), + + #[error(transparent)] + HueError(#[from] hue::error::HueError), + + #[error("Attribute type 0x{0:02x} not supported")] + UnsupportedAttrType(u8), + + #[error(transparent)] + PackedStructError(#[from] packed_struct::PackingError), +} + +pub type ZclResult = Result; diff --git a/crates/zcl/src/frame.rs b/crates/zcl/src/frame.rs new file mode 100644 index 0000000..3342608 --- /dev/null +++ b/crates/zcl/src/frame.rs @@ -0,0 +1,100 @@ +use std::fmt::Debug; +use std::io::Read; + +use byteorder::{BigEndian as BE, ReadBytesExt}; +use packed_struct::prelude::*; + +use crate::error::ZclResult; + +#[derive(PrimitiveEnum_u8, Debug, Clone, Copy, Eq, PartialEq)] +pub enum ZclFrameType { + ProfileWide = 0x00, + ClusterSpecific = 0x01, +} + +#[derive(PrimitiveEnum_u8, Debug, Clone, Copy, Eq, PartialEq)] +pub enum ZclFrameDirection { + ClientToServer = 0x00, + ServerToClient = 0x01, +} + +#[derive(PackedStruct, Clone, Copy)] +#[packed_struct(size_bytes = "1", bit_numbering = "lsb0")] +pub struct ZclFrameFlags { + #[packed_field(bits = "0..2", ty = "enum")] + pub frame_type: ZclFrameType, + + #[packed_field(bits = "2")] + pub manufacturer_specific: bool, + + #[packed_field(bits = "3", ty = "enum")] + pub direction: ZclFrameDirection, + + #[packed_field(bits = "4")] + pub disable_default_response: bool, +} + +impl Debug for ZclFrameFlags { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let ft = match self.frame_type { + ZclFrameType::ProfileWide => "PW", + ZclFrameType::ClusterSpecific => "CS", + }; + let dir = match self.direction { + ZclFrameDirection::ClientToServer => "C2S", + ZclFrameDirection::ServerToClient => "S2C", + }; + write!(f, "[ ")?; + write!(f, "ft:{ft}, ")?; + write!(f, "ms:{}, ", u8::from(self.manufacturer_specific))?; + write!(f, "dir:{dir}, ")?; + write!(f, "ddr:{}", u8::from(self.disable_default_response))?; + write!(f, " ]")?; + Ok(()) + } +} + +#[derive(Debug, Clone, Copy)] +pub struct ZclFrame { + pub flags: ZclFrameFlags, + pub mfcode: Option, + pub seqnr: u8, + pub cmd: u8, +} + +impl ZclFrame { + pub fn parse(data: &mut impl Read) -> ZclResult { + let flags = ZclFrameFlags::unpack(&[data.read_u8()?])?; + + let mfcode = if flags.manufacturer_specific { + Some(data.read_u16::()?) + } else { + None + }; + + let seqnr = data.read_u8()?; + let cmd = data.read_u8()?; + + Ok(Self { + flags, + mfcode, + seqnr, + cmd, + }) + } + + #[must_use] + pub fn c2s(&self) -> bool { + self.flags.direction == ZclFrameDirection::ClientToServer + } + + #[must_use] + pub fn cluster_specific(&self) -> bool { + self.flags.frame_type == ZclFrameType::ClusterSpecific + } + + #[must_use] + pub const fn manufacturer_specific(&self) -> bool { + self.flags.manufacturer_specific + } +} diff --git a/crates/zcl/src/lib.rs b/crates/zcl/src/lib.rs new file mode 100644 index 0000000..f910d84 --- /dev/null +++ b/crates/zcl/src/lib.rs @@ -0,0 +1,4 @@ +pub mod attr; +pub mod cluster; +pub mod error; +pub mod frame; diff --git a/doc/bifrost.service.ex b/doc/bifrost.service.ex new file mode 100644 index 0000000..c26bdc1 --- /dev/null +++ b/doc/bifrost.service.ex @@ -0,0 +1,36 @@ +[Unit] +Description=Bifrost Bridge +After=network.target + +[Service] +Type=simple + +# Make it possible for unprivileged processes to bind to low ports (< 1024) +# This is needed to run port 80 + 443 without being root. +AmbientCapabilities=CAP_NET_BIND_SERVICE + +# If bifrost should fail for some reason, wait 20s and restart it, +# no matter the cause +Restart=always +RestartSec=20s + +# To use these settings, create a bifrost user + group: +# +# adduser --group bifrost --system bifrost +# +User=bifrost +Group=bifrost + +# This assumes you want to run the bifrost server in: +# +# /data/bifrost/ +# +# with the executable at: +# +# /data/bifrost/bifrost +# +WorkingDirectory=/data/bifrost +ExecStart=/data/bifrost/bifrost + +[Install] +WantedBy=multi-user.target diff --git a/doc/comparison-with-diyhue.md b/doc/comparison-with-diyhue.md new file mode 100644 index 0000000..2c816fb --- /dev/null +++ b/doc/comparison-with-diyhue.md @@ -0,0 +1,69 @@ +## Comparison with diyHue + +You might already be familiar with [diyHue](https://github.com/diyhue/diyHue), +an existing project that aims to emulate a Philips Hue Bridge. + +diyHue is a well-established project, that integrates with countless +servers/services/light systems, and emulates many Hue Bridge features. + +However, I have been frustrated with diyHue's MQTT integration, and its fairly +poor performance when operating more than a handful of lights at a time. Since +diyHue always sends individual messages to each light in a group, large rooms +can get quite slow (multiple seconds for every adjustment, no matter how minor). + +Currently, diyHue does not support Zigbee groups (or MQTT groups) at all, +whereas Bifrost is written specifically to present Zigbee2MQTT groups as Hue +Bridge "rooms". For zigbee/mqtt use cases, this massively increases performance +and reliability. + +Another thing about diyHue that frustrates me to no end, is the lack of +(working) support for push notifications. If you use the Hue App to control a +diyHue bridge, you will notice that it does not react to any changes from other +phones, home automation, etc. Also, the reported light states (on/off, color, +temperature, etc) are sometimes just wrong. + +Overall, diyHue can do an impressive number of things, but it seems to have some +pretty rough edges. + +Just to clarify, I've enjoyed using diyHue, and I wish them all the best. It's +also very useful, both as a home automation service, and a reverse engineering +resource. + +However, if you're also using one or more Zigbee2MQTT servers to control Zigbee +devices, feel free to give Bifrost a try. It might be a better fit for your use +case. + +In any case, feedback always welcome. + + +| Feature | diyHue | Bifrost | +|--------------------------------------|-----------------------------------------|-------------------------------------------| +| Language | Python | Rust | +| Project scope | Broad (supports countless integrations) | Narrow (specifically targets Zigbee2MQTT) | +| Use Hue Bridge as backend | ✅ | ❌ | +| Usable from Homeassistant | ✅ (as a Hue Bridge) | ✅ (as a Hue Bridge) | +| Control individual lights | ✅ | ✅ | +| Good performance for groups of light | ❌ (sends a message per light) | ✅ (uses zigbee groups) | +| Connect to Zigbee2MQTT | (✅) (but only one server) | ✅ (multiple servers supported) | +| Auto-detection of color features | ❌ (needs manual configuration) | ✅ | +| Create Zigbee2MQTT scenes | ❌ | ✅ | +| Recall Zigbee2MQTT scenes | ❌ | ✅ | +| Learn Zigbee2MQTT scenes | ❌ | ✅ | +| Delete Zigbee2MQTT scenes | ❌ | ✅ | +| Join new zigbee lights | ✅ | ❌ | +| Add/remove lights to rooms | ❌ | ✅ | +| Live state of lights in Hue app | ❌ [^1] | ✅ | +| Multiple type of backends | ✅ | ❌ (only Zigbee2MQTT) | +| Entertainment zones | ✅ | ✅ | +| Zigbee Entertainment mode support | ❌ | ✅ | +| Hue effects (fireplace, candle, etc) | (✅) (partial) | ✅ | +| Routines / Wake up / Go to sleep | ✅ | ❌ (planned) | +| Remote services | (✅) (only with Hue essentials) | ❌ | +| Add custom lights and switches | ✅ | ❌ | + +[^1]: Light state synchronization (i.e. consistency between hue emulator, hue + app and reality) seems to be, unfortunately, somewhat brittle in diyHue. See + for example: + * https://github.com/diyhue/diyHue/issues/883 + * https://github.com/diyhue/diyHue/issues/835 + * https://github.com/diyhue/diyHue/issues/795 diff --git a/doc/config-reference.md b/doc/config-reference.md new file mode 100644 index 0000000..681349f --- /dev/null +++ b/doc/config-reference.md @@ -0,0 +1,178 @@ +## Configuration reference + +Bifrost + +```yaml +# Bifrost section [optional!] +# +# Contains bifrost server settings +# [usually omitted, to use defaults] +bifrost: + # name of yaml file to write state database to + state_file: "state.yaml" + + # name of x509 certificate for https + # + # if this file is missing, bifrost will generate one for you + # + # if this file exists, bifrost will check that the mac address + # matches the specified server mac address + # + # to generate a fresh certificate, rename/move this file + # (this might require pairing the Hue App again) + cert_file: "cert.pem" + +# Bridge section +# +# Settings for hue bridge emulation +bridge: + name: Bifrost + mac: 00:11:22:33:44:55 + ipaddress: 10.0.0.12 + netmask: 255.255.255.0 + gateway: 10.0.0.1 + timezone: Europe/Copenhagen + + # HTTP port for emulated bridge + # + # beware: most client programs do NOT support non-standard ports. + # This is for advanced users (e.g. bifrost behind a reverse proxy) + http_port: 80 + + # HTTPS port for emulated bridge + # + # beware: most client programs do NOT support non-standard ports. + # This is for advanced users (e.g. bifrost behind a reverse proxy) + https_port: 443 + + # DTLS port for emulated bridge (Hue Entertainment streaming) + # + # beware: client programs do NOT support non-standard ports. + # For advanced users (e.g. bifrost behind a port forwarded firewall) + entm_port: 2100 + +# Zigbee2mqtt section +# +# Make a sub-section for each zigbee2mqtt server you want to connect +# +# The server names ("some-server", "other-with-tls") are used for logging, +# but have no functional impact. +# +# NOTE: Be sure to use DIFFERENT names for different servers. +# Otherwise the yaml parser will consider it the same server! +z2m: + some-server: + # The websocket url for z2m, starting with "ws://". + # + # For z2m version 2.x, the url must end in `/api?token=`. + # For z2m version 1.x, this is optional, but supported. + # + # Therefore, Bifrost will adjust the urls if needed. + # A message will be logged with the rewritten url if this happens. + # + # NOTE: The z2m default token is literally the string "your-secret-token", + # so if unsure, append "/api?token=your-secret-token". + # + # Example: + # + # If your z2m frontend is listening on 10.00.0.100:8080, this + # is the resuling config: + # + url: ws://10.00.0.100:8080/api?token=your-secret-token + + other-with-tls: + # This will work, but Bifrost will generate a warning that the url has been + # adapted to include "/api?token=your-secret-token". + # + # NOTE: Using "wss://" instead of "ws://" enables TLS for this connection. + url: wss://10.10.0.102:8080 + + # Disable TLS verify [optional!] + # + # If this parameter is included, and has a value of "true", TLS certificate + # verification will be disabled! + # + # NOTE: From a security standpoint, this is almost as bad as disabling + # encryption entirely. If having a secure connection is important to you, + # DO NOT enable this option. + # + # If you're using self-signed certificates, enabling this option will allow + # Bifrost to connect to your z2m server. + disable_tls_verify: false + + # Group prefix [optional!] + # + # If you specify this parameter, *only* groups with this prefix + # will be visible from this z2m server. The prefix will be removed. + # + # Example: + # + # With a group_prefix of "bifrost_", the group "bifrost_kitchen" + # will be available as "kitchen", but the group "living_room" will + # be hidden instead. + # + group_prefix: bifrost_ + + # Streaming mode ("Entertainment mode" / "Hue Sync") maximum frames per second + # [optional!] + # + # This is the maximum number of light updates attempted per second. + # + # The incoming data stream (from a Sync Box, Hue Sync for Windows/Mac, + # or some other client) determines the maximum possible fps. + # + # For example, if Bifrost only receives light updates at 10 fps, setting + # this limit to 20 will still only cause the lights to update at 10 fps. + # + # On the other hand, if the streaming client sends faster than this limit, + # frames will be dropped to avoid going over it. + # + # If not specified, uses a default of 20, which is an attempt to balance + # responsiveness against load on the Zigbee mesh. + # + # Because of the smoothing algorithm Bifrost uses, the results will look + # *better* if this is not set higher than needed. + # + # For example, 30 fps content will look good at 10, 20 or 30 streaming_fps, + # but worse at streaming_fps: 60, because the frame-to-frame transition + # time will be wrong for the content. + # + # Rules of thumb(s), for best results: + # - Higher numbers mean greater load on your Zigbee mesh. + # - If your mesh starts lagging or becoming unresponsive, try a lower number. + # - Even values as low as 5 fps looks pretty good. + # - There usually no reason to go above 60. + # - Have fun experimenting :-) + streaming_fps: 20 + ... + +# Rooms section [optional!] +# +# This section allows you to map zigbee2mqtt "friendly names" to +# a human-readable description you provide. +# +# Each entry under "rooms" must match a zigbee2mqtt "friendly name", +# and can contain the following keys: (both are optional) +# +# name: The human-readable name presented in the API (for the Hue App, etc) +# +# icon: The icon to use for this room. Must be selected from the following +# list of icons supported by the Hue App: +# +# attic balcony barbecue bathroom bedroom carport closet computer dining +# downstairs driveway front_door garage garden guest_room gym hallway +# home kids_bedroom kitchen laundry_room living_room lounge man_cave +# music nursery office other pool porch reading recreation staircase +# storage studio terrace toilet top_floor tv upstairs +# +rooms: + office_group: + name: Office 1 + icon: office + + carport_group: + name: Carport Lights + icon: carport + + ... +``` diff --git a/doc/docker-compose-install.md b/doc/docker-compose-install.md new file mode 100644 index 0000000..3de254c --- /dev/null +++ b/doc/docker-compose-install.md @@ -0,0 +1,35 @@ +## Building From Source + +When you have these things available, you can install Bifrost by running these commands: + +```sh +git clone https://github.com/chrivers/bifrost +cd bifrost +``` + +Then rename or copy our `config.example.yaml`: + +```sh +cp config.example.yaml config.yaml +``` + +And edit it with your favorite editor to your liking (see +[configuration reference](config-reference.md)). + +If you want to put your configuration file or the certificates Bifrost creates somewhere +else, you also need to adjust the mount paths in the `docker-compose.yaml`. Otherwise, +just leave the default values. + +Now you are ready to run the app with: + +```sh +docker compose up -d +``` + +This will build and then start the app on your Docker instance. + +To view the logs, run the following command: + +```sh +docker logs bifrost +``` diff --git a/doc/docker-image-install.md b/doc/docker-image-install.md new file mode 100644 index 0000000..9a47e02 --- /dev/null +++ b/doc/docker-image-install.md @@ -0,0 +1,29 @@ +## Using Docker Pull + +Pull the latest image from Github Container Registry: + +```sh +docker pull ghcr.io/chrivers/bifrost:latest +``` + +Curl and rename the example configuration file: + +```sh +curl -O https://raw.githubusercontent.com/chrivers/bifrost/master/config.example.yaml +cp config.example.yaml config.yaml +``` + +And edit it with your favorite editor to your liking (see +[configuration reference](config-reference.md)). + +Now run the Docker Container: + +```sh +docker run -v $(pwd)/config.yaml:/app/config.yaml ghcr.io/chrivers/bifrost:latest +``` + +To view the logs, run the following command: + +```sh +docker logs bifrost +``` diff --git a/doc/how-to-find-mac-linux.md b/doc/how-to-find-mac-linux.md new file mode 100644 index 0000000..60d200a --- /dev/null +++ b/doc/how-to-find-mac-linux.md @@ -0,0 +1,17 @@ +## How to find your mac address (Linux) + +On Linux, you can use the `ip -c addr` command to find the mac address: + +``` +$ ip -c addr +... +3: enp36s0: mtu 1500 qdisc mq state UP group default qlen 1000 + link/ether 00:11:22:33:44:55 brd ff:ff:ff:ff:ff:ff + inet 10.12.0.20/24 brd 10.12.0.255 scope global enp36s0 + valid_lft forever preferred_lft forever +... +``` + +Here we see an interface called `enp36s0` that has the mac address `00:11:22:33:44:55`. + +You will see multiple interfaces - use the one with your IP address listed. diff --git a/doc/hue-zigbee-clusters.md b/doc/hue-zigbee-clusters.md new file mode 100644 index 0000000..4d1660a --- /dev/null +++ b/doc/hue-zigbee-clusters.md @@ -0,0 +1,336 @@ +# The Color of Magic: Reversing the Hue Zigbee Clusters + +This document, which builds on the [initial work](hue-zigbee-format.md), aims to +compile all available information about the custom Zigbee messages used by +Philips Hue devices, and in particular, lights. + +The following text refers to commands and attributes on Hue devices. This has +been researched using the following units: + +## "Hue Bulb" + + - "Hue white and color ambiance E27 1100lm" + - Model `LCA006` + - Firmware 1.122.2 (20240902) + +## "Hue Gradient strip" + + - "Hue Play gradient lightstrip for PC" + - Model `LCX005` + - Firmware 1.122.2 (20240902) + +## Nomenclature + +The following short names are used to refer to zigbee data types and concepts: + +| Name used here | Zigbee meaning | +|----------------|---------------------------------------------| +| N/S | Attribute not supported | +| u8 | Unsigned, 8-bit integer | +| u16 | Unsigned, 16-bit integer | +| i16 | Signed, 16-bit integer | +| b8 | 8-bit bitmap value | +| b32 | 32-bit bitmap value | +| e8 | 8-bit enum value | +| hex | "Octet string" (byte array) in hex notation | + +# Cluster 0xFC00: Hue Button events + +Used by hue buttons to report button events and other state changes. + +## Cluster-specific commands + +### Command 0: Button Event + +These are mostly documented elsewhere, and because they are button events, they +are not the main focus of this document. + +# Cluster 0xFC01: Entertainment + +This cluster is used to control "Entertainment Zones", a defining feature of the +Hue ecosystem. + +## Cluster-specific commands + +### Command 1: Update entertainment zone + +This is the major command used to send a "frame" of Hue Entertainment data. + +Sending it to a Hue bulb will cause that bulb to repeat it in broadcast mode, +for other devices to pick up. + +```text + ┌───────────┬───┬───┬───┬───┬───┬───┬───┬───┐ + │ Byte Bit ► 7 │ 6 │ 5 │ 4 │ 3 │ 2 │ 1 │ 0 │ + ├─▼─────────┼───┴───┴───┴───┴───┴───┴───┴───┤ + │ 0 │ .counter │ + │ │ │ + │ 1 │ │ + │ │ │ + │ 2 │ │ + │ │ │ + │ 3 │ │ + ├───────────┼───────────────────────────────┤ + │ 4 │ .smoothing │\ + │ │ Defaults to 0x0400 │ } Smoothing factor + │ 5 │ (encoded as "0004") │/ + ├───────────┼───────────────────────────────┤ + │ 6 │ Light data block 0 │\ + │ │ │ \ + │ .. │ │ } Repeated for each light + │ │ │ / + │ 12 │ │/ + ├───────────┼───────────────────────────────┤ + : 13 : Light data block 1 : + : : : +``` + +The "smoothing factor" is a value that controls how agressively the +color/brightness will change from the previous frame. A value of `0x0000` is the +fastest possible (and generally not very pleasant to look at), while a value of +`0x1000` is quite slow, giving very smooth animations, but without any quick changes. + +Very high values (e.g. above `0x4000`) are so slow that they are unlikely to be +useful in most cases. + +The existing Hue Entertainment clients all seem to use `0x0400`, which is a +reasonable starting point. Note that this property does NOT seem to be exposed +over any known API, but it is available over Bifrost. + +Each "light data block" is a 7-byte packed structure describing the desired +state for a light (a bulb, or single segment of a multi-segment light source). + +```text + ┌───────────┬───┬───┬───┬───┬───┬───┬───┬───┐ + │ Byte Bit ► 7 │ 6 │ 5 │ 4 │ 3 │ 2 │ 1 │ 0 │ + ├─▼─────────┼───┴───┴───┴───┴───┴───┴───┴───┤ + │ 0 │ .addr │ + │ │ Zigbee address (or alias) │ + │ 1 │ for the target light │ + ├───────────┼───────────┬───────────────────┤ + │ 2 │(low 3 bit)│ .mode (5 bit enum)│ + │ │─ ─ ─ ─ ─ ─└───────────────────┤ + │ 3 │ .brightnes (high 8 bits) │ + ├───────────┼───────────────────────────────┤ + │ 4 │ .color_x (low 8 bits) │\ + │ ├───────────────┐─ ─ ─ ─ ─ ─ ─ ─│ \ + │ 5 │ (low 4 bits) │ (high 4 bits) │ same format as for composite updates + │ │─ ─ ─ ─ ─ ─ ─ ─└───────────────┤ / + │ 6 │ .color_y (high 8 bits) │/ + └───────────┴───────────────────────────────┘ +``` + +The `.mode` field is an odd one. Only two values have ever been observed: + +```rust +// the names might change, as we learn more about these bits +enum LightRecordMode { + Segment = 0b00000, + Device = 0b01011, +} +``` + +Normal bulbs must be contacted with the `LightRecordMode::Device` option, while +updates for segments on a gradient strip must use the `LightRecordMode::Segment` +mode. Otherwise, the entire segment only lights up in the first color. + +Current hypothesis: This values determines if real network addresses or virtual +segment addresses are used, but this is currently not tested. + +### Command 3: Synchronize entertainment zone + +This command is used to synchronize the sequence number in an entertainment +group. The first two bytes are unknown. + +```c +struct { + x0: u8, // only seen as 0 + x1: u8, // seen as 0 or 1. unknown function + counter: u32, // frame counter for entertainment group +} +``` + +### Command 4: Retrieve segment mapping + +This command is used to retrieve the segment mapping for a hue multi-segment +light. + +#### Request + +A single byte is sent. Only observed as `00` (might be an index for highly +addressable devices?). + +#### Response + +```c +struct Response { + x0: u8, // unknown + x1: u8, // unknown + count: u8, // number of segments + segments: [Segment], // segment descriptors +} + +struct Segment { + start: u8, // start index for segment + length: u8, // segment length +} +``` + +As an example, the following is a real response from a Hue Gradient light strip: + +``` + ┌───┬───First segment descriptor + │ │ + 00 00 07 00 01 01 01 02 01 03 01 04 01 05 01 06 01 + │ │ │ │ + └header┘ └───────Seven segment descriptors───────┘ +``` + +This tells us the segments are arranged thus: + + - Start at `00`, length `01` + - Start at `01`, length `01` + - Start at `02`, length `01` + - ... + +These are all length 1. In other words, the layout is: + + `0, 1, 2, 3, 4, 5, 6` + +### Command 7: Configure segments for entertainment mode (req/rsp) + +Hue Entertainment frames consists of brightness and color data for up to 10 +lights, all in a single frame. + +Each light is identified by 2 bytes containing its zigbee network (short) +address. + +For Hue devices that contain multiple lights (such as gradient strips), this +presents a problem, since the entire strip only has a single zigbee address! + +To solve that problem, this command can be used on multi-segment devices to +configure each segment with a virtual address. + +#### Request + +```c +struct { + count: u16, + addresses: [count x u16], +} +``` + +Here is an example of a command that sets seven virtual addresses for a gradient +light strip with 7 segments: + +``` + ┌───┬───Segment index 0 + │ │ + 00 07 97 d2 98 d2 99 d2 9a d2 9b d2 9c d2 9d d2 + │ │ │ │ + └cnt┘ └───────Seven segment indices───────────┘ + +``` + +After this, the segments will respond the these addresses: + + - `0xD297` + - `0xD298` + - `0xD299` + - `0xD29A` + - `0xD29B` + - `0xD29C` + - `0xD29D` + +#### Response + +```c +struct { + x0: u16, +} +``` + +The only observed response is `0000`, which probably indicates success. + +Running this command on a Hue device that does not have multiple segments (i.e, +a regular Hue bulb) gets a "Command Not Supported" standard Zigbee response, so +returning `0000` seems to be a safe way to detect success. + +## Attributes + +| Attr | Type | Desc | Strip | Bulb | Firmware | +|--------|------|----------------------------|-------|------|----------------------------------------| +| `0000` | `b8` | ? | `0F` | `0B` | | +| `0001` | `e8` | ? | `00` | `00` | | +| `0002` | `u8` | Probably max segment count | `0A` | N/S | | +| `0003` | `u8` | Probably gradient-related | `04` | N/S | | +| `0004` | `u8` | Probably segment count | `07` | N/S | | +| `0005` | `u8` | Light balance factor | `FE` | `FE` | Fails on `1.76.11`, works on `1.122.2` | + +Notice that attributes `0002`, `0003` and `0004` are not present on the hue +bulb. This supports the idea that these attributes are gradient-related. + +So far the only attribute known on this cluster is `0x005`, which sets the light +level balancing for entertainment mode. + +This is a feature where lights can be dimmed relatively, so certain lights +aren't blindingly bright. Just like regular brightness updates, the valid range +is `0x01` to `0xFE`. This should always be set to `0xFE`, unless you want to dim +the light in entertainment mode. + +# Cluster 0xFC02 + +Never seen. Maybe they skipped a number? + +# Cluster 0xFC03: Gradients, Effects, Animations + +## Cluster-specific commands + +### Command 0: Write combined state + +This is perhaps the single most complicated Hue command. It is used to +simultaneously set all supported properties of a Hue bulb. + +It has been extensively [documented in a separate document](hue-zigbee-format.md). + +After setting the state with this command, it can be read back as property +`0x0002` (see below). + +## Attributes + +Sample values: + +| Attr | Type | Desc | Strip | Bulb | +|--------|-------|-----------------|--------------------|--------------------| +| `0001` | `b32` | ? | `0000000F` | `00000007` | +| `0002` | `hex` | Composite state | `0700010a6e01` | `070001176f01` | +| `0010` | `b16` | ? | `0001` | `0001` | +| `0011` | `b64` | ? | `000000000003FE0E` | `000000000003FE0E` | +| `0012` | `b32` | ? | `00000003` | `00000000` | +| `0013` | `b16` | ? | `0007` | N/S | +| `0031` | `u16` | ? | `04E2` | N/S | +| `0032` | `u8` | ? | `00` | N/S | +| `0033` | `u8` | ? | `00` | N/S | +| `0034` | `u8` | ? | `03` | N/S | +| `0035` | `u8` | ? | `FE` | N/S | +| `0036` | `u8` | ? | `4F` | N/S | +| `0038` | `u16` | ? | `0007` | N/S | + +The bulb supports noticably fewer properties, which makes it likely that the +missing ones are related to gradient handling. + +# Cluster 0xFC04 + +Very rarely observed. Only seen with ZCL: Read Attributes. + +## Attributes + +| Attr | Type | Desc | Strip | Bulb | +|--------|-------|------|------------|------------| +| `0000` | `b16` | ? | `1007` | `1007` | +| `0001` | `b16` | ? | `0000` | `0000` | +| `0002` | `b16` | ? | `0000` | `0000` | +| `0010` | `u32` | ? | `00000000` | `00000000` | +| `0011` | `u32` | ? | `00000000` | `00000000` | +| `0012` | `u32` | ? | `00000000` | `00000000` | +| `0013` | `u32` | ? | `00000000` | `00000000` | diff --git a/doc/hue-zigbee-format.md b/doc/hue-zigbee-format.md new file mode 100644 index 0000000..31824aa --- /dev/null +++ b/doc/hue-zigbee-format.md @@ -0,0 +1,457 @@ +# Zigbee format for Philips Hue manufacturer-specific light updates + +## Introduction + +Philips hue lights support zigbee frames in a manufacturer-specific format, on cluster 0xFC03. + +This type of message is necessary to support many of the advanced features in Hue lights, such as: + + - Multiple colors ("gradient") in LED strips + - Light Effects ("Candle", "Fireplace", etc) + - Combining effects with color settings + +Several attempts have been made to reverse this format before, but none have +managed to get everything decoded, although many attempts and techniques have +been employed. A few examples of the ongoing work: + + - + - + - + +The best (and newest) work so far, is probably Krzysztof Jagiełło's "Hue Gradient Command Wizard": + + - + +This one gets much right, but is still missing quite a few details. + +Another invaluable resource when researching XY-based lights, is Thomas Lochmatter's RGB/XY converter: + + - + +## Examples + +Here are some examples of the zigbee messages discussed in this document (hex encoded): + + - `50010000135000fffff3620c400f5bf4120d400f5b0cf4f43858` + - `ab00012e6f2f40100f7f` + - `51010104000d30040000fa441eb7cb49bff65f1800` + - `19000132518f530400` + - `1100000800` + - `bb0001feb575156904000a80` + - `51010104001350020000fa441e590834b7cb49ff8857bff65f2800` + +At first glance, there's no obvious repeating pattern or fixed header in this +format, but with a combination of careful analysis and applied elbow grease, we +have managed to reverse the format in its entirety. + +## Current state of the art (in zigbee-herdsman-converters) + +The current state of the art in zigbee-herdsman-converters +(`srd/lib/philips.ts`) has patchy support for a few advanced features, but is +riddled with errors and inaccuracies. It also suffers from being written before a +complete understanding of the format was available, widely using "magic" numbers +that happen to work, although they may not be a good fit in the bigger picture. + +There is great potential for improving zigbee-herdsman-converters, using the +information found in this repository (and in particular, this file). + +# Frame format + +Okay, let's start looking at the actual format now. + +Philips hue lights work as simple I/O devices with up to 9 properties. + +Each message to cluster 0xFC03 can update any chosen subset of these properties +as desired. + +When sending an update, only the included properties will be affected. All other +properties will retain their previous values. + +### Header + +The first two bytes of the message form a little-endian integer that contains +these flags: + +```text + FEDCBA98 76543210 + xxxxxxxx xxxxxxxx + |||||||| |||||||| + |||||||| |||||||'--> ON_OFF + |||||||| ||||||'---> BRIGHTNESS + |||||||| |||||'----> COLOR_MIREK + |||||||| ||||'-----> COLOR_XY + |||||||| |||'------> FADE_SPEED + |||||||| ||'-------> EFFECT_TYPE + |||||||| |'--------> GRADIENT_PARAMS + |||||||| '---------> EFFECT_SPEED + |||||||| + |||||||'-----------> GRADIENT_COLORS + ||||||'------------> UNUSED_9 + |||||'-------------> UNUSED_A + ||||'--------------> UNUSED_B + |||'---------------> UNUSED_C + ||'----------------> UNUSED_D + |'-----------------> UNUSED_E + '------------------> UNUSED_F +``` + +As an example, let us consider the message: + +`530101c00400135000000094031fda1955b98347f00468c426792800` + +The first bytes are [0x53, 0x01], which is 0x0153 in little-endian: + +```text + 0x01 0x53 + / \ / \ + .......1 01010011 + | |||||||| + | |||||||'--> ON_OFF + | ||||||'---> BRIGHTNESS + | |||||'----> - + | ||||'-----> - + | |||'------> FADE_SPEED + | ||'-------> - + | |'--------> GRADIENT_PARAMS + | '---------> - + | + '-----------> GRADIENT_COLORS +``` + +Now we can read the properties that have their corresponding flag set. + +## Field order + +The fields are always read this this order: + +| Field | Size | +|-------------------|----------| +| `ON_OFF` | 1 byte | +| `BRIGHTNESS` | 1 byte | +| `COLOR_MIREK` | 2 bytes | +| `COLOR_XY` | 4 bytes | +| `FADE_SPEED` | 2 bytes | +| `EFFECT_TYPE` | 1 byte | +| `GRADIENT_COLORS` | variable | +| `EFFECT_SPEED` | 1 byte | +| `GRADIENT_PARAMS` | 2 bytes | + +After reading the message in this manner, there shouldn't be any bytes left over. + +However, this is seemingly a protocol that has been extended a few times (as can +be observed from the newest flags occupying the highest bits, for instance). + +This would theoretically allow older devices to simply ignore all unknown flags, +and the corresponding "tail" of the message, while still reacting to the +properties they understand. + +It is unknown if Hue devices actually operate in this way, or if they would +reject messages they do not fully understand. + +Certainly, invalid messages with known flags are readily reject (and thus, +completely ignored), if they cannot be parsed 100% successfully. + +### Property: `ON_OFF` + +Size: 1 byte. + +Light is turned off if `0`, or on otherwise. + +### Property: `BRIGHTNESS` + +Size: 1 byte. + +NOTE: Values `0` and `255` are INVALID. + +Valid range is `1..254` (dimmest to brightest, respectively) + +### Property: `COLOR_MIREK` + +Size: 2 bytes (little-endian) + +Contains the color temperature in MIREK. + +Typically valid range is `153` - `500` (both inclusive). + +### Property: `COLOR_XY` + +Size: 2 + 2 bytes (X, Y) + +The color of the light, in XY format. + +These coordinates are encoded as 16-bit little-endian integers, each +representing a fixed-point number in the range `0`..`1`. + +Here 0 represents `0.0` and `0xFFFF` represents `1.0`. + +### Property: `FADE_SPEED` + +Size: 2 bytes (little-endian) + +This number sets the transition speed for applying the new properties. + +A value of 0 makes all transitions as fast as possible (practically +instant). Typical practical values are in the range `2..8`. + +While values above 0x100 are possible, these cause very slow +transitions. However, the animation is running inside the light, so this could +be a good way to enable smooth, lightweight light transitions. + +### Property: `EFFECT_TYPE` + +Size: 1 byte (specifically, [`zigbee::EffectType`]) + +| Name | Value | +|--------------|-------| +| `NoEffect` | 0x00 | +| `Candle` | 0x01 | +| `Fireplace` | 0x02 | +| `Prism` | 0x03 | +| `Sunrise` | 0x09 | +| `Sparkle` | 0x0a | +| `Opal` | 0x0b | +| `Glisten` | 0x0c | +| `Sunset` | 0x0d | +| `Underwater` | 0x0e | +| `Cosmos` | 0x0f | +| `Sunbeam` | 0x10 | +| `Enchant` | 0x11 | + +This enables one of the specific, known effects in the [`zigbee::EffectType`] +enum. Most (all?) effects allow setting other properties (such as color xy or +color temperature) while the effect is active. + +This is how custom effects like "Purple Fireplace" or "Blue Candle" from the Hue +app are activated. + +### Property: `GRADIENT_COLORS` + +For gradient light strips, this property allows setting a number of independent +colors at once. + +The gradient colors black is the most complicated of the property data blocks +used in this format. It has the following layout: + +```text + + ┌───────────┬───┬───┬───┬───┬───┬───┬───┬───┐ + │ Byte Bit ► 7 │ 6 │ 5 │ 4 │ 3 │ 2 │ 1 │ 0 │ + ├─▼─────────┼───┴───┴───┴───┴───┴───┴───┴───┤ + │ 0 │ .size (excluding this field) │ + ├───────────┼───────────────┬───────────────┤ + │ 1 │ .color_count │ MUST be zero! │ + ├───────────┼───────────────┴───────────────┤ + │ 2 │ .gradient_style │ + ├───────────┼───────────────────────────────┤ + │ 3 │ Reserved (seems unused) │ + │ │ │ + │ 4 │ │ + ├───────────┼───────────────────────────────┤ + │ 5+3*index │ .color_x (low 8 bits) │\ + │ ├───────────────┐ - - - - - - - │ \ + │ 6+3*index │ (low 4 bits) │ (high 4 bits) │ Repeated {.color_count} times + │ │ - - - - - - - └───────────────┤ / + │ 7+3*index │ .color_y (high 8 bits) │/ + ├───────────┼───────────────────────────────┤ + : : : + : : : +``` + +The gradient style *must* be one of these values: + +```rust +pub enum GradientStyle { + Linear = 0x00, + Scattered = 0x02, + Mirrored = 0x04, +} +``` + +### Property: `GRADIENT_COLORS`: Color encoding + +The gradient colors format can specify up to and including 9 colors, even for +light strips with fewer than 9 segments. Any attempt to specify 10 or more +colors will result in the entire message being rejected. + +Each color is packed into 3 bytes, representing 12 bits for the `X` and `Y` +color coordinate, respectively. These bytes are packed in an odd way. The +following code snippet demonstrates how to unpack them: + +```rust +let bytes: [u8; 3] = [0x11, 0x22, 0x33]; +let x = u16::from(bytes[0]) | u16::from(bytes[1] & 0x0F) << 8; +let y = u16::from(bytes[2]) << 4 | u16::from(bytes[1] >> 4); +``` + +And packing: + +```rust,no_run +let x = 0x123; +let y = 0x456; +let bytes: [u8; 3] = [ + (x & 0xFF) as u8, + (((x >> 8) & 0x0F) | ((y & 0x0F) << 4)) as u8, + (y >> 4 & 0xFF) as u8, +]; +``` + +These 12-bit values are fractional values, but NOT in the unit range 0..1 as +might be expected. + +Instead, the coordinates are scaled so that precision is not wasted on useless +coordinates outside the visible light spectrum, for example. + +Other implementations all seem to make this guess about the scaling: + +```rust +const max_x: f32 = 0.7347; +const max_y: f32 = 0.8431; +``` + +As far as I can tell, these numbers appeared at one point in someone's +implementation (probably as a best guess), and have been mercilessly copy-pasted +since then. If anyone can show a good source for why these numbers would be +correct, please let me know! + +Now, the X coordinate makes a lot of sense, and is right as far as I can tell. + +The value 0.7347 is the maximum X value inside the visible light spectrum. +For a visual illustration of this, see . + +The "Wide" color gamut also has this exact number in its specification, as the X +value of the "Red" coordinate, specifically. + +However, the Y value doesn't match any source I can find. + +If the scaling matches the Wide Gamut, the Y value (maximum height) should be +`0.8264`. If it matched the top of the visible light area, it should be around +`0.836`. I have no idea where `0.8431` comes from! + +From experimentation, I have determined that the most likely candidates are the +outer bounds of the wide gamut, leading to the following values: + +```rust +const MAX_X: f64 = 0.7347; +const MAX_Y: f64 = 0.8264; +``` + +These are then the scaling values used when serializing/deserializing the 24-bit +(X,Y) values in the gradient colors. + +In other words, X values from `0` to `0xFFF` represent the X-coordinates `0.0` +to `0.7347`, while Y values in the same range represent Y-coordinates from `0.0` +to `0.8264`. + +### Property: `EFFECT_SPEED` + +Size: 1 byte + +This property controls the animation speed for effects (see `EFFECT_TYPE`). + +All values in the range `0`..`255` seem to be allowed, with `0` being the +slowest and `255` the fastest. + +Curiously, the Hue app does not use the full range for all effects, and at high +values, some animations are rendered so quickly that they start to break down. + +A good starting point seems to be 128 (representing 0.5). + +### Property: `GRADIENT_PARAMS` + +Size: 2 bytes (`scale`, `offset`) + +The gradient parameters block contain two bytes, describing the `scale` (first +byte) and `offset` (second byte). + +Both bytes are in fixed-point format, with the upper 5 bits representing the +integer portion, and the lower 3 bits the fractional part: + +```text +| 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 | +\ / \ / + \________________/ \________/ + integer fraction + +``` + +Here are some examples of translating to/from this fixed-point format: + +| Encoded value | Quotient | Numeric value | +|---------------|----------|---------------| +| 0x00 | 0/8 | 0.0 | +| 0x01 | 1/8 | 0.125 | +| 0x04 | 4/8 | 0.5 | +| 0x08 | 8/8 | 1.0 | +| 0x38 | 56/8 | 7.0 | +| 0x39 | 57/8 | 7,125 | +| 0x3a | 58/8 | 7.25 | + +#### Property: `GRADIENT_PARAMS`: `scale` + +For a gradient light strip, the `scale` value determines how "wide" the gradient +colors are rendered. Specifically, a gradient light strip will scale the +gradient colors to fit "scale" colors on the strip. + +As an example, the Hue Play gradient lightstrip for PC (model `LCX005`) has 42 +LEDs, but only 7 independent sections. Each group of 6 LEDs can thus be thought +of as one "pixel". + +With such a low pixel count, `scale` greatly affects the resulting colors when +updating the gradient strip. + +For sharp, clear colors, the scale should match the number of segments in the +light strip. Again using the `LCX005` as an example, a good value for `Linear` +gradient mode is `0x38` (since this represents `7.0` in the fixed-point +notation). + +This allows each of the colors to fit exactly in a segment on the strip, but +other values are possible too, of course. For example, `0x08` (= `1.0`) will +show one color on the entire strip, while `0x10` (= `2.0`) will show a smooth +transition between the first and second colors. + +The above example is for the `Linear` gradient style only. The `Mirrored` mode +uses the middle segment as the base, and thus has 3 available (mirrored) +segments on either side, on a 7-segment light strip. + +The `Scattered` mode always shows colors aligned with segments. As a result, +`scale` is ignored in this mode. + +| Gradient style: | Linear | Mirrored | Scattered | +|-----------------------------------|--------|----------|-----------| +| Scale to show single color | 0x08 | 0x08 | N/A | +| Scale to fade between 2 colors | 0x10 | 0x10 | N/A | +| Scale to fit colors to 7 segments | 0x38 | 0x20 | N/A | + +NOTE: The `scale` value MUST BE at least `0x08` (= `1.0`) + +NOTE: The `scale` value `0x00` is a special condition. It stretches the gradient +colors to exactly fit the gradient strip. Kind of a "zoom to fit" option. + +#### Property: `GRADIENT_PARAMS`: `offset` + +This property is simpler than `scale`. When rendering the gradient colors to the +light strip, the first `offset` lights are skipped. + +For example, assume we have these abstract values for gradient light colors: + +```text +|-------------------| +| A | B | C | D | E | +|-------------------| +``` + +With `offset` set to `0`, the colors will be rendered starting with `A`, so +[`A`, `B`, `C`, ...]. + +With `offset` set to `0x08` (= `1.0` in the fixed-point format), the first color +shown will be `B`, so [`B`, `C`, `D`, ...], and so forth. + +For *fractional* offset values, proportional blending is used to emulate the +sub-pixel offset. With an offset of `0x04` (= `0.5`), the rendered colors will be: + + - `50% A + 50% B` + - `50% B + 50% C` + - `50% C + 50% D` + - ... + +If unsure, a good value for `offset` is `0x00`. diff --git a/doc/implementation-status.md b/doc/implementation-status.md new file mode 100644 index 0000000..971c705 --- /dev/null +++ b/doc/implementation-status.md @@ -0,0 +1,47 @@ +## Implementation status + +### Legacy (V1 API) + +| Feature | Endpoint | Status | +|-------------|--------------------------------------|--------------| +| Minimal API | `/api/config`, `/api/:userid/config` | ✅ | +| Lights | `/api/:user/lights` | ✅ (partial) | +| Groups | `/api/:user/groups` | ✅ (partial) | +| Scenes | `/api/:user/scenes` | ✅ (partial) | +| Sensors | `/api/:user/sensors` | ❌ | + +| Endpoint | GET | PUT | POST | DELETE | +|----------------------------|-----|-----|------|--------| +| `/` | - | - | ✅ | - | +| `/config` | ✅ | - | - | - | +| `/:user` | ✅ | - | - | - | +| `/:user/config` | ✅ | ❌ | ❌ | ❌ | +| `/:user/lights` | ✅ | ❌ | ❌ | ❌ | +| `/:user/groups` | ✅ | ❌ | ❌ | ❌ | +| `/:user/scenes` | ✅ | ❌ | ❌ | ❌ | +| `/:user/capabilities` | ✅ | ❌ | ❌ | ❌ | +| `/:user/` | ❌ | ❌ | ❌ | ❌ | +| `/:user/lights/:id` | ✅ | - | - | ❌ | +| `/:user/groups/:id` | ✅ | - | - | ❌ | +| `/:user/scenes/:id` | ✅ | - | - | ❌ | +| `/:user/lights/:id/state` | - | ✅ | - | - | +| `/:user/groups/:id/action` | - | ✅ | - | - | + + +### Modern (V2 API) + +| Feature | Implemented | Notes | +|-----------------|-------------|----------------------------------------------------------------------------------------------------------| +| Authentication | ❌ | No authentication! Everybody has full access | +| Config | ✅ | | +| Event streaming | ✅ | Can send updates for lights, groups, rooms, scenes | +| Lights | ✅ | Supports on/off, color temperature, full color | +| Groups | ✅ | Automatically mapped to rooms | +| Scenes | ✅ | Scenes can be created, recalled, deleted. Scenes found in zigbee2mqtt will be imported, and auto-learned | + +| Feature | GET | POST | PUT | DELETE | +|---------------------|-----|------|--------------|--------| +| Lights | ✅ | - | ✅ (partial) | - | +| Groups | ✅ | ❌ | ✅ (partial) | ❌ | +| Scenes | ✅ | ✅ | ✅ (partial) | ✅ | +| Entertainment Zones | ✅ | ✅ | ✅ | ❌ | diff --git a/doc/logo-title-320x80.png b/doc/logo-title-320x80.png new file mode 100644 index 0000000..74acd37 Binary files /dev/null and b/doc/logo-title-320x80.png differ diff --git a/doc/logo-title-640x160.png b/doc/logo-title-640x160.png new file mode 100644 index 0000000..180a4d0 Binary files /dev/null and b/doc/logo-title-640x160.png differ diff --git a/doc/logo-title.svg b/doc/logo-title.svg new file mode 100644 index 0000000..38b6a23 --- /dev/null +++ b/doc/logo-title.svg @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Bifrost + diff --git a/doc/logo.svg b/doc/logo.svg new file mode 100644 index 0000000..9f75f9e --- /dev/null +++ b/doc/logo.svg @@ -0,0 +1,52 @@ + + + + + + + + + diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..c695944 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,10 @@ +services: + bifrost: + build: . + container_name: bifrost + restart: unless-stopped + network_mode: host + volumes: + # If you followed the guide on our readme, these paths work out of the box + - ./config.yaml:/app/config.yaml + - ./certs:/app/certs diff --git a/examples/convert-product-data.rs b/examples/convert-product-data.rs new file mode 100644 index 0000000..cbc9e9b --- /dev/null +++ b/examples/convert-product-data.rs @@ -0,0 +1,56 @@ +// Tool to discover DeviceProductData unknown in bifrost::hue::devicedb. +// +// cat samples/*.json | jq '.data? | .[]? | select(.product_data?.hardware_platform_type) | .product_data' | cargo run --example=convert-product-data +// +// Any output from the above command will be devices currently unknown in the device database. + +use std::io::stdin; + +use serde_json::Deserializer; + +use hue::api::DeviceProductData; +use hue::devicedb::{SimpleProductData, product_data}; + +fn print_std(obj: DeviceProductData) { + let spd = SimpleProductData { + manufacturer_name: &obj.manufacturer_name, + product_name: &obj.product_name, + product_archetype: obj.product_archetype, + hardware_platform_type: obj.hardware_platform_type.as_deref(), + }; + println!( + "{:?} => {},", + obj.model_id, + format!("{spd:?}").replace("SimpleProductData ", "SPD ") + ); +} + +fn main() { + pretty_env_logger::formatted_builder() + .filter_level(log::LevelFilter::Debug) + .parse_default_env() + .init(); + + let stream = Deserializer::from_reader(stdin()).into_iter::(); + + for obj in stream { + let Ok(obj) = obj else { + continue; + }; + + let pd = product_data(&obj.model_id); + if pd.is_none() { + if obj.manufacturer_name == DeviceProductData::SIGNIFY_MANUFACTURER_NAME { + if let Some(hpt) = obj.hardware_platform_type { + println!( + "{:?} => SPD::signify({:?}, {:?}, {:?}),", + obj.model_id, obj.product_name, obj.product_archetype, hpt, + ); + continue; + } + } + + print_std(obj); + } + } +} diff --git a/examples/ez-parse.rs b/examples/ez-parse.rs new file mode 100644 index 0000000..1b5825c --- /dev/null +++ b/examples/ez-parse.rs @@ -0,0 +1,211 @@ +#![allow(clippy::cast_possible_truncation, clippy::match_same_arms)] + +use std::fmt::Debug; +use std::io::{Cursor, stdin}; + +use clap::Parser; +use serde::Deserialize; + +use bifrost::error::ApiResult; +use zcl::cluster; +use zcl::error::ZclResult; +use zcl::frame::{ZclFrame, ZclFrameDirection, ZclFrameType}; + +#[macro_use] +extern crate log; + +pub fn f64_str<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + use std::str::FromStr; + let s = String::deserialize(deserializer)?; + f64::from_str(&s).map_err(serde::de::Error::custom) +} + +pub fn u16_hex<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + Ok(u16::from_be( + u16::from_str_radix(&s, 16).map_err(serde::de::Error::custom)?, + )) +} + +pub fn u16_hex_opt<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let opt = Option::::deserialize(deserializer)?; + if let Some(s) = opt { + Ok(Some(u16::from_be( + u16::from_str_radix(&s, 16).map_err(serde::de::Error::custom)?, + ))) + } else { + Ok(None) + } +} + +pub fn vec_hex_opt<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let opt = Option::::deserialize(deserializer)?; + if let Some(s) = opt { + Ok(hex::decode(s).map_err(serde::de::Error::custom)?) + } else { + Ok(vec![]) + } +} + +#[derive(Debug, Deserialize)] +pub struct Record { + /* pub src_mac: String, */ + /* pub cmd: Option, */ + #[serde(deserialize_with = "f64_str")] + pub time: f64, + + pub index: u64, + + #[serde(deserialize_with = "u16_hex")] + pub src: u16, + + #[serde(deserialize_with = "u16_hex")] + pub dst: u16, + + #[serde(deserialize_with = "u16_hex")] + pub cluster: u16, + + #[serde(deserialize_with = "vec_hex_opt")] + pub data: Vec, +} + +fn parse(rec: &Record, no_index: bool) -> ZclResult<()> { + if rec.data.is_empty() { + return Ok(()); + } + + let mut cur = Cursor::new(&rec.data); + let frame = ZclFrame::parse(&mut cur)?; + + let data = &rec.data[cur.position() as usize..]; + + let src = hex::encode(rec.src.to_be_bytes()); + let dst = hex::encode(rec.dst.to_be_bytes()); + let flags = frame.flags; + let cmd = frame.cmd; + let cls = rec.cluster; + let index = if no_index { 0 } else { rec.index }; + + let describe = |cat: &str, desc: ZclResult>| { + let dir = if flags.direction == ZclFrameDirection::ClientToServer { + " :>" + } else { + "<: " + }; + + match desc { + Ok(Some(desc)) => { + if desc.is_empty() { + return; + } + + info!( + "[{index:6}] [{src} -> {dst}] {flags:?} [{cls:04x}] {cmd:02x} {dir} {cat}{desc} {}", + hex::encode(data) + ); + } + Ok(None) => { + warn!( + "[{index:6}] [{src} -> {dst}] {flags:?} [{cls:04x}] {cmd:02x} {dir} {cat}Unknown {}", + hex::encode(data) + ); + } + Err(err) => { + error!( + "[{index:6}] [{src} -> {dst}] {flags:?} [{cls:04x}] {cmd:02x} {dir} FAILED {}: {err}", + hex::encode(data) + ); + } + } + }; + + if frame.flags.frame_type == ZclFrameType::ProfileWide { + describe("", cluster::standard::describe(&frame, data)); + return Ok(()); + } + + match rec.cluster { + 0x0003 => describe("Effect:", Ok(cluster::effects::describe(&frame, data))), + 0x0004 => describe("Group:", Ok(cluster::groups::describe(&frame, data))), + 0x0005 => describe("Scene:", Ok(cluster::scenes::describe(&frame, data))), + 0x0006 => describe("OnOff:", Ok(cluster::onoff::describe(&frame, data))), + 0x0008 => describe("LevelCtrl:", Ok(cluster::levelctrl::describe(&frame, data))), + + 0x0019 => { + // suppress OTA messages + } + + 0x0021 => { + // suppress ZGP (Zigbee Green Power) messages + } + + 0x0300 => describe("ColorCtrl:", Ok(cluster::colorctrl::describe(&frame, data))), + + 0x0406 => { + // suppress occypancy sensing + } + + 0x1000 => describe( + "Commissioning:", + cluster::commissioning::describe(&frame, data), + ), + + 0xFC01 => describe("HueEnt:", cluster::hue_fc01::describe(&frame, data)), + 0xFC03 => describe("HueCmp:", cluster::hue_fc03::describe(&frame, data)), + + _ => describe("UNKNOWN:", Ok(None)), + } + + Ok(()) +} + +#[derive(Parser, Debug)] +#[command(version, long_about = None)] +#[command(about("Parses hue zigbee frames (as hex-encoded lines on stdin)"))] +struct Args { + /// Ignore packet number (easier diffing, since all packets are numbered 0) + #[arg(short, name = "no-index", default_value_t = false)] + no_index: bool, +} + +fn main() -> ApiResult<()> { + pretty_env_logger::formatted_builder() + .filter_level(log::LevelFilter::Debug) + .parse_default_env() + .init(); + + let args = Args::parse(); + + for line in stdin().lines() { + let line = line?; + + match serde_json::from_str::(line.trim()) { + Ok(data) => { + if let Err(err) = parse(&data, args.no_index) { + error!("Failed parse: {err}"); + eprintln!(" {line:<40}"); + eprintln!(" {data:?}"); + } + } + + Err(err) => { + error!("Failed to parse json: {err}"); + eprintln!(" {line:<40}"); + } + } + } + + Ok(()) +} diff --git a/examples/generate-server-cert.rs b/examples/generate-server-cert.rs new file mode 100644 index 0000000..76d4a5f --- /dev/null +++ b/examples/generate-server-cert.rs @@ -0,0 +1,28 @@ +use std::io::{Write, stdout}; + +use clap::Parser; +use der::{EncodePem, pem::LineEnding}; +use mac_address::MacAddress; +use p256::pkcs8::EncodePrivateKey; +use rsa::rand_core::OsRng; + +use bifrost::{error::ApiResult, server::certificate}; + +#[derive(Debug, Parser)] +struct Cli { + mac: MacAddress, +} + +fn main() -> ApiResult<()> { + let args = Cli::parse(); + + let secret_key = p256::SecretKey::random(&mut OsRng); + let cert = certificate::generate(&secret_key, args.mac)?; + + let mut out = stdout().lock(); + + out.write_all(secret_key.to_pkcs8_pem(LineEnding::LF)?.as_bytes())?; + out.write_all(cert.to_pem(LineEnding::LF)?.as_bytes())?; + + Ok(()) +} diff --git a/examples/hz-make.rs b/examples/hz-make.rs new file mode 100644 index 0000000..7b80ea0 --- /dev/null +++ b/examples/hz-make.rs @@ -0,0 +1,27 @@ +use hue::api::ColorGamut; +use hue::zigbee::{GradientParams, GradientStyle, HueZigbeeUpdate}; + +use bifrost::error::ApiResult; + +fn main() -> ApiResult<()> { + pretty_env_logger::formatted_builder() + .filter_level(log::LevelFilter::Debug) + .parse_default_env() + .init(); + + let hz = HueZigbeeUpdate::new() + .with_on_off(true) + .with_brightness(0x20) + .with_gradient_colors( + GradientStyle::Linear, + vec![ColorGamut::GAMUT_C.red, ColorGamut::GAMUT_C.red], + )? + .with_gradient_params(GradientParams { + scale: 0x38, + offset: 0x00, + }); + + println!("{}", hex::encode(&hz.to_vec()?)); + + Ok(()) +} diff --git a/examples/hz-parse.rs b/examples/hz-parse.rs new file mode 100644 index 0000000..518855a --- /dev/null +++ b/examples/hz-parse.rs @@ -0,0 +1,113 @@ +#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] +use std::io::{BufRead, Cursor, stdin}; + +use itertools::Itertools; +use log::warn; +use packed_struct::PrimitiveEnumDynamicStr; + +use hue::zigbee::{Flags, GradientColors, HueZigbeeUpdate}; + +use bifrost::error::ApiResult; + +#[allow(clippy::format_push_string)] +#[must_use] +pub fn present_gradcolors(grad: &GradientColors) -> String { + let mut res = format!( + "{}-{}-{}-{:<9}", + grad.header.nlights, + grad.header.resv0, + grad.header.resv2, + grad.header.style.to_display_str(), + ); + for p in &grad.points { + let x = (p.x * 1000.0) as u32; + let y = (p.y * 1000.0) as u32; + res += &format!(" {x:03}.{y:03}"); + } + res +} + +fn show(data: &[u8]) -> ApiResult<()> { + let flags = Flags::from_bits(u16::from(data[0]) | (u16::from(data[1]) << 8)).unwrap(); + + let mut cur = Cursor::new(data); + let hz = HueZigbeeUpdate::from_reader(&mut cur)?; + + let desc = format!( + " {:04x} : {:2} : {:2} : {:4} : {:11} : {:<10} : {:2} : {:<55} : {:4} : {:4} ", + flags.bits(), + hz.onoff.map(|x| format!("{x:02x}")).unwrap_or_default(), + hz.brightness + .map(|x| format!("{x:02x}")) + .unwrap_or_default(), + hz.color_mirek + .map(|x| format!("{x:04x}")) + .unwrap_or_default(), + hz.color_xy + .map(|xy| { format!("{:.3},{:.3}", xy.x, xy.y) }) + .unwrap_or_default(), + hz.effect_type + .map(|x| x.to_display_str()) + .unwrap_or_default(), + hz.effect_speed + .map(|x| format!("{x:02x}")) + .unwrap_or_default(), + hz.gradient_colors + .as_ref() + .map(present_gradcolors) + .unwrap_or_default(), + hz.gradient_params + .map(|gt| format!("{:02x}{:02x}", gt.scale, gt.offset)) + .unwrap_or_default(), + hz.fade_speed + .map(|x| format!("{x:04x}")) + .unwrap_or_default(), + ); + + println!( + "|{desc}| {} {}", + hex::encode(cur.fill_buf()?), + flags.iter_names().map(|(name, _)| name).join(" | ") + ); + + Ok(()) +} + +fn check(orig: &[u8]) -> ApiResult<()> { + let mut cur = Cursor::new(orig); + let hz = HueZigbeeUpdate::from_reader(&mut cur)?; + + let mut dest = Cursor::new(vec![]); + hz.serialize(&mut dest)?; + + let data = dest.into_inner(); + + if orig != data { + warn!("DIFF:"); + warn!(" {} before", hex::encode(orig)); + warn!(" {} after", hex::encode(&data)); + } + + Ok(()) +} + +fn main() -> ApiResult<()> { + pretty_env_logger::formatted_builder() + .filter_level(log::LevelFilter::Debug) + .parse_default_env() + .init(); + + eprintln!( + "| flag | on | br | mrek | (colx,coly) | effect ty. | es | gradient data | grad | fade |" + ); + + for line in stdin().lines() { + let line = line?; + let data = hex::decode(&line)?; + println!("==================== {line:<40}"); + show(&data)?; + check(&data)?; + } + + Ok(()) +} diff --git a/examples/normalize-hue-get.rs b/examples/normalize-hue-get.rs new file mode 100644 index 0000000..0bebd22 --- /dev/null +++ b/examples/normalize-hue-get.rs @@ -0,0 +1,274 @@ +use std::collections::HashMap; + +use clap::Parser; +use clap_stdin::FileOrStdin; +use json_diff_ng::compare_serde_values; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use serde_json::{Deserializer, Value}; + +use bifrost::error::ApiResult; +use hue::api::ResourceRecord; +use hue::legacy_api::{ + ApiConfig, ApiGroup, ApiLight, ApiResourceLink, ApiRule, ApiScene, ApiSchedule, ApiSensor, +}; + +fn false_positive((a, b): &(&Value, &Value)) -> bool { + a.is_number() && b.is_number() && a.as_f64() == b.as_f64() +} + +#[allow(clippy::large_enum_variant)] +#[derive(Serialize, Deserialize, Debug)] +#[serde(untagged)] +pub enum Input { + V1 { + config: Value, + groups: HashMap, + lights: HashMap, + resourcelinks: HashMap, + rules: HashMap, + scenes: HashMap, + schedules: HashMap, + sensors: HashMap, + }, + V2 { + errors: Vec, + data: Vec, + }, + V2Flat(Value), +} + +fn compare(before: &Value, after: &Value, report: bool) -> ApiResult { + let diffs = compare_serde_values(before, after, true, &[]).unwrap(); + let all_diffs = diffs.all_diffs(); + + if !all_diffs + .iter() + .any(|x| x.1.values.is_none_or(|q| !false_positive(&q))) + { + return Ok(true); + } + + /* in report mode, hide diff details */ + if report { + return Ok(false); + } + + log::error!("Difference detected"); + eprintln!("--------------------------------------------------------------------------------"); + println!("{}", serde_json::to_string(before)?); + eprintln!("--------------------------------------------------------------------------------"); + println!("{}", serde_json::to_string(after)?); + eprintln!("--------------------------------------------------------------------------------"); + for (d_type, d_path) in all_diffs { + if let Some(ref ab) = d_path.values { + if false_positive(ab) { + continue; + } + } + match d_type { + json_diff_ng::DiffType::LeftExtra => { + eprintln!(" - {d_path}"); + } + json_diff_ng::DiffType::Mismatch => { + eprintln!(" * {d_path}"); + } + json_diff_ng::DiffType::RightExtra => { + eprintln!(" + {d_path}"); + } + json_diff_ng::DiffType::RootMismatch => { + eprintln!("{d_type}: {d_path}"); + } + } + } + eprintln!(); + + Ok(false) +} + +pub struct Normalizer<'a> { + name: &'a str, + items: usize, + errors: usize, + width: usize, + index: usize, + report: bool, +} + +impl<'a> Normalizer<'a> { + #[must_use] + pub const fn new(name: &'a str, width: usize, report: bool) -> Self { + Self { + name, + index: 0, + items: 0, + errors: 0, + width, + report, + } + } + + pub const fn error(&mut self) { + self.errors += 1; + } + + pub fn parse(&mut self, obj: Value) -> ApiResult + where + T: DeserializeOwned + std::fmt::Debug, + { + let data: Result = serde_json::from_value(obj); + Ok(data.inspect_err(|err| { + self.errors += 1; + log::error!( + "{name:width$} | >> Parse error {err} (object index {index})", + name = self.name, + width = self.width, + index = self.index + ); + /* eprintln!("{}", &serde_json::to_string(&before)?); */ + })?) + } + + fn roundtrip(&mut self, item: &Value) -> ApiResult<()> + where + T: Serialize + DeserializeOwned + std::fmt::Debug, + { + let value = self.parse::(item.clone())?; + self.items += 1; + self.index += 1; + let after = serde_json::to_value(&value)?; + + if !compare(item, &after, self.report)? { + self.errors += 1; + } + Ok(()) + } + + fn test(&mut self, item: &Value) + where + T: Serialize + DeserializeOwned + std::fmt::Debug, + { + let _ = self.roundtrip::(item); + } + + pub fn summary(&self) { + let errors = self.errors; + let items = self.items; + let name = self.name; + let width = self.width; + if errors > 0 { + log::error!("{name:width$} | {items:5} items | {errors:5} errors |"); + } else { + log::info!("{name:width$} | {items:5} items | OK |"); + } + } +} + +fn process_file(file: FileOrStdin, width: usize, report: bool) -> ApiResult<()> { + let name = if file.is_stdin() { + "" + } else { + &file.filename().to_string() + }; + let stream = Deserializer::from_reader(file.into_reader().unwrap()).into_iter::(); + + let mut nml = Normalizer::new(name, width, report); + + for obj in stream { + let obj = obj?; + + let Ok(msg) = nml.parse::(obj) else { + continue; + }; + + match msg { + Input::V1 { + config, + groups, + lights, + resourcelinks, + rules, + scenes, + schedules, + sensors, + } => { + /* log::info!("v1 detected"); */ + nml.test::(&config); + + for item in groups.values() { + nml.test::(item); + } + for item in lights.values() { + nml.test::(item); + } + for item in resourcelinks.values() { + nml.test::(item); + } + for item in rules.values() { + nml.test::(item); + } + for item in scenes.values() { + nml.test::(item); + } + for item in schedules.values() { + nml.test::(item); + } + for item in sensors.values() { + nml.test::(item); + } + } + Input::V2 { data, .. } => { + /* log::info!("v2 detected"); */ + for item in data { + nml.test::(&item); + } + } + Input::V2Flat(item) => { + /* log::info!("v2flat detected"); */ + nml.test::(&item); + } + } + } + + nml.summary(); + + Ok(()) +} + +#[derive(Parser, Debug)] +#[command(about, long_about = None)] +struct Args { + /// input files + #[arg(name = "files")] + files: Vec, + + /// show only per-file summary + #[arg(short, name = "report", default_value_t = false)] + report: bool, +} + +impl Args { + pub fn longest_filename(&self) -> usize { + self.files + .iter() + .map(|b| b.filename().len().max(5)) + .max() + .unwrap_or(5) + } +} + +fn main() -> ApiResult<()> { + pretty_env_logger::formatted_builder() + .filter_level(log::LevelFilter::Debug) + .parse_default_env() + .init(); + + let args = Args::parse(); + let width = args.longest_filename(); + + for file in args.files { + process_file(file, width, args.report)?; + } + + Ok(()) +} diff --git a/examples/wscat.rs b/examples/wscat.rs new file mode 100644 index 0000000..46b9953 --- /dev/null +++ b/examples/wscat.rs @@ -0,0 +1,30 @@ +use clap::Parser; +use futures::StreamExt; +use tokio_tungstenite::{connect_async, tungstenite::Message}; + +use bifrost::error::ApiResult; + +#[derive(Parser, Debug)] +struct Args { + /// Url to websocket () + url: String, +} + +#[tokio::main] +async fn main() -> ApiResult<()> { + let args = Args::parse(); + + let (mut socket, _) = connect_async(args.url).await?; + + loop { + let Some(pkt) = socket.next().await else { + break; + }; + + let Message::Text(txt) = pkt? else { break }; + + println!("{txt}"); + } + + Ok(()) +} diff --git a/examples/wsinput.rs b/examples/wsinput.rs new file mode 100644 index 0000000..7420121 --- /dev/null +++ b/examples/wsinput.rs @@ -0,0 +1,105 @@ +use std::io::{IsTerminal, Write}; + +use clap::Parser; +use futures::{SinkExt, StreamExt}; +use serde::Deserialize; +use serde_json::Value; +use tokio::io::{AsyncBufReadExt, BufReader, stdin}; +use tokio::select; +use tokio_tungstenite::{connect_async, tungstenite::Message}; + +use bifrost::error::ApiResult; +use z2m::api::RawMessage; + +#[macro_use] +extern crate log; + +#[derive(Parser, Debug)] +struct Args { + /// Url to websocket () + url: String, +} + +#[derive(Deserialize)] +pub struct Log { + pub level: String, + message: String, +} + +#[allow(clippy::redundant_pub_crate)] +#[tokio::main] +async fn main() -> ApiResult<()> { + pretty_env_logger::formatted_builder() + .write_style(pretty_env_logger::env_logger::fmt::WriteStyle::Always) + .filter_level(log::LevelFilter::Debug) + .parse_default_env() + .init(); + + let args = Args::parse(); + + let (mut socket, _) = connect_async(args.url).await?; + + let tty = std::io::stdout().is_terminal(); + + let mut lines = BufReader::new(stdin()).lines(); + + loop { + if !tty { + print!("> "); + std::io::stdout().flush()?; + } + + select! { + Ok(Some(line)) = lines.next_line() => { + if line.is_empty() { + continue; + } + + /* println!("{line}"); */ + + let req: RawMessage = match serde_json::from_str(&line) { + Ok(res) => res, + Err(err) => { + error!("Failed to parse: {err}"); + continue + }, + }; + + /* info!("req: {req:?}"); */ + match serde_json::to_string(&req) { + Ok(pkt) => { + /* println!("Parsed: {}", &pkt); */ + socket.send(Message::text(pkt)).await?; + } + Err(err) => { + error!("Nope: {err}"); + } + } + } + Some(Ok(pkt)) = socket.next() => { + if let Message::Text(txt) = pkt { + let msg: RawMessage = serde_json::from_str(&txt)?; + if msg.topic != "bridge/info" && msg.topic != "bridge/definitions" && msg.topic != "bridge/devices" { + if msg.topic == "bridge/logging" { + let log: Log = serde_json::from_value(msg.payload)?; + if log.message.starts_with("zhc:tz: Read result") { + let body = &log.message.split('\'').nth(2).unwrap()[2..]; + info!("{body}"); + let rsp: Value = serde_json::from_str(body)?; + info!("{rsp:?}"); + } else if log.message.contains("UNSUPPORTED_ATTRIBUTE") { + error!("Unsupported attribute"); + } else { + info!("{:?}", log.message); + } + } else { + println!("{msg:?}"); + } + } + } else { + println!("{pkt:?}"); + } + } + } + } +} diff --git a/examples/wsparse.rs b/examples/wsparse.rs new file mode 100644 index 0000000..2b9f4a7 --- /dev/null +++ b/examples/wsparse.rs @@ -0,0 +1,159 @@ +#![allow(clippy::match_same_arms)] + +use std::io::stdin; + +use log::LevelFilter; + +use bifrost::error::ApiResult; +use z2m::api::{Availability, Message, RawMessage}; +use z2m::update::DeviceUpdate; + +#[allow(clippy::too_many_lines)] +#[tokio::main] +async fn main() -> ApiResult<()> { + pretty_env_logger::formatted_builder() + .filter_level(LevelFilter::Debug) + .init(); + + for line in stdin().lines() { + let line = line?; + + let raw_data = serde_json::from_str::(&line); + + let Ok(raw_msg) = raw_data else { + log::error!("INVALID LINE: {:#?}", raw_data); + continue; + }; + + /* bridge messages are those on bridge/+ topics */ + + if raw_msg.topic.starts_with("bridge/") { + let data = serde_json::from_str(&line); + + let Ok(msg) = data else { + log::error!("INVALID LINE [bridge]: {:#?}", data); + continue; + }; + + match &msg { + Message::BridgeInfo(obj) => { + println!("{:#?}", obj.config_schema); + } + Message::BridgeLogging(obj) => { + println!("{obj:#?}"); + } + Message::BridgeExtensions(obj) => { + println!("{obj:#?}"); + } + Message::BridgeDevices(devices) => { + for dev in devices { + println!("{dev:#?}"); + } + } + Message::BridgeGroups(obj) => { + println!("{obj:#?}"); + } + Message::BridgeDefinitions(obj) => { + println!("{obj:#?}"); + } + Message::BridgeState(obj) => { + println!("{obj:#?}"); + } + Message::BridgeEvent(obj) => { + println!("{obj:#?}"); + } + Message::BridgeConverters(obj) => { + println!("{obj:#?}"); + } + Message::BridgeGroupMembersAdd(obj) => { + println!("{obj:#?}"); + } + Message::BridgeGroupMembersRemove(obj) => { + println!("{obj:#?}"); + } + Message::BridgeOptions(obj) => { + println!("{obj:#?}"); + } + Message::BridgeTouchlinkScan(obj) => { + println!("{obj:#?}"); + } + Message::BridgePermitJoin(obj) => { + println!("{obj:#?}"); + } + Message::BridgeNetworkmap(obj) => { + println!("{obj:#?}"); + } + Message::BridgeDeviceConfigureReporting(obj) => { + println!("{obj:#?}"); + } + Message::BridgeDeviceRemove(obj) => { + println!("{obj:#?}"); + } + Message::BridgeDeviceOptions(obj) => { + println!("{obj:#?}"); + } + Message::BridgeDeviceOtaUpdateCheck(obj) => { + println!("{obj:#?}"); + } + Message::BridgeConfig(obj) => { + println!("{obj:#?}"); + } + Message::BridgeResponseGroupAdd(obj) => { + println!("{obj:#?}"); + } + Message::BridgeResponseGroupRemove(obj) => { + println!("{obj:#?}"); + } + Message::BridgeResponseGroupRename(obj) => { + println!("{obj:#?}"); + } + Message::BridgeResponseGroupOptions(obj) => { + println!("{obj:#?}"); + } + } + + continue; + } + + /* everything that ends in /availability are online/offline updates */ + + if raw_msg.topic.ends_with("/availability") { + let data = serde_json::from_value::(raw_msg.payload); + + let Ok(_msg) = data else { + log::error!("INVALID LINE [availability]: {}", data.unwrap_err()); + eprintln!("{line}"); + eprintln!(); + continue; + }; + + continue; + } + + /* everything that ends in /action are action events */ + + if raw_msg.topic.ends_with("/action") { + // FIXME: parse action events + continue; + } + + /* everything else: device updates */ + + let data = serde_json::from_value::(raw_msg.payload); + + let Ok(msg) = data else { + log::error!("INVALID LINE [device]: {}", data.unwrap_err()); + eprintln!("{line}"); + eprintln!(); + continue; + }; + + /* having unknown fields is not an error. they are simply not mapped */ + /* if !msg.__.is_empty() { */ + /* log::warn!("Unknown fields found: {:?}", msg.__.keys()); */ + /* } */ + println!("{msg:#?}"); + } + + Ok(()) +} diff --git a/examples/z2mdump.rs b/examples/z2mdump.rs new file mode 100644 index 0000000..f46fb82 --- /dev/null +++ b/examples/z2mdump.rs @@ -0,0 +1,56 @@ +use clap::Parser; +use futures::StreamExt; +use hyper::Uri; +use serde::Deserialize; +use tokio_tungstenite::{connect_async, tungstenite::Message}; + +use bifrost::error::ApiResult; + +#[derive(Parser, Debug)] +struct Args { + /// Url to websocket (example: ) + url: Uri, +} + +#[derive(Debug, Deserialize)] +struct Z2mMessage { + topic: String, +} + +#[tokio::main] +async fn main() -> ApiResult<()> { + pretty_env_logger::formatted_builder() + .filter_level(log::LevelFilter::Debug) + .parse_default_env() + .init(); + + let args = match Args::try_parse() { + Ok(args) => args, + Err(err) => { + log::error!("Argument error: {err}"); + std::process::exit(1); + } + }; + + let (mut socket, _) = connect_async(args.url).await?; + + loop { + let Some(pkt) = socket.next().await else { + break; + }; + + let Message::Text(txt) = pkt? else { break }; + + let json: Z2mMessage = serde_json::from_str(&txt)?; + + if json.topic.starts_with("bridge/") { + log::info!("Got message [{}]", json.topic); + println!("{txt}"); + } else { + log::info!("No more z2m bridge messages"); + break; + } + } + + Ok(()) +} diff --git a/src/backend/mod.rs b/src/backend/mod.rs new file mode 100644 index 0000000..5b73b6e --- /dev/null +++ b/src/backend/mod.rs @@ -0,0 +1 @@ +pub mod z2m; diff --git a/src/backend/z2m/backend_event.rs b/src/backend/z2m/backend_event.rs new file mode 100644 index 0000000..e7dff88 --- /dev/null +++ b/src/backend/z2m/backend_event.rs @@ -0,0 +1,526 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; + +use hue::clamp::Clamp; +use hue::effect_duration::EffectDuration; +use hue::zigbee::{GradientParams, GradientStyle, HueZigbeeUpdate}; +use tokio::time::sleep; +use uuid::Uuid; + +use bifrost_api::backend::BackendRequest; +use hue::api::{ + Entertainment, EntertainmentConfiguration, GroupedLight, GroupedLightUpdate, Light, + LightEffectsV2Update, LightGradientMode, LightUpdate, RType, Resource, ResourceLink, Room, + RoomUpdate, Scene, SceneActive, SceneStatus, SceneStatusEnum, SceneUpdate, + ZigbeeDeviceDiscoveryUpdate, +}; +use hue::error::HueError; +use hue::stream::HueStreamLightsV2; +use z2m::update::{DeviceEffect, DeviceUpdate}; + +use crate::backend::z2m::Z2mBackend; +use crate::backend::z2m::entertainment::EntStream; +use crate::backend::z2m::websocket::Z2mWebSocket; +use crate::error::ApiResult; +use crate::model::state::AuxData; + +impl Z2mBackend { + #[allow(clippy::match_same_arms)] + fn make_hue_specific_update(upd: &LightUpdate) -> ApiResult { + let mut hz = HueZigbeeUpdate::new(); + + if let Some(grad) = &upd.gradient { + hz = hz.with_gradient_colors( + grad.mode.map_or(GradientStyle::Linear, Into::into), + grad.points.iter().map(|c| c.color.xy).collect(), + )?; + + hz = hz.with_gradient_params(GradientParams { + scale: match grad.mode { + Some(LightGradientMode::InterpolatedPalette) => 0x28, + Some(LightGradientMode::InterpolatedPaletteMirrored) => 0x18, + Some(LightGradientMode::RandomPixelated) => 0x38, + None => 0x18, + }, + offset: 0x00, + }); + } + + if let Some(LightEffectsV2Update { + action: Some(act), .. + }) = &upd.effects_v2 + { + if let Some(fx) = act.effect { + hz = hz.with_effect_type(fx.into()); + } + if let Some(speed) = &act.parameters.speed { + hz = hz.with_effect_speed(speed.unit_to_u8_clamped()); + } + if let Some(mirek) = &act.parameters.color_temperature.and_then(|ct| ct.mirek) { + hz = hz.with_color_mirek(*mirek); + } + if let Some(color) = &act.parameters.color { + hz = hz.with_color_xy(color.xy); + } + } + + if let Some(act) = &upd.timed_effects { + if let Some(fx) = act.effect { + hz = hz.with_effect_type(fx.into()); + } + + if let Some(duration) = act.duration { + hz = hz.with_effect_duration(EffectDuration::from_ms(duration)?); + } + } + + Ok(hz) + } + + async fn backend_light_update( + &self, + z2mws: &mut Z2mWebSocket, + link: &ResourceLink, + upd: &LightUpdate, + ) -> ApiResult<()> { + let Some(topic) = self.rmap.get(link) else { + return Ok(()); + }; + + let mut lock = self.state.lock().await; + + // We cannot recover .mode from backend updates, since these only contain + // the gradient colors. So we have no choice, but to update the mode + // here. Otherwise, the information would be lost. + if let Some(mode) = upd.gradient.as_ref().and_then(|gr| gr.mode) { + lock.update::(&link.rid, |light| { + if let Some(gr) = &mut light.gradient { + gr.mode = mode; + } + })?; + } + let hue_effects = lock.get::(link)?.effects.is_some(); + drop(lock); + + /* step 1: send generic light update */ + let transition = upd + .dynamics + .as_ref() + .and_then(|d| d.duration.map(|duration| f64::from(duration) / 1000.0)) + .or_else(|| { + if upd.dimming.is_some() || upd.color_temperature.is_some() || upd.color.is_some() { + Some(0.4) + } else { + None + } + }); + let mut payload = DeviceUpdate::default() + .with_state(upd.on.map(|on| on.on)) + .with_brightness(upd.dimming.map(|dim| dim.brightness / 100.0 * 254.0)) + .with_color_temp(upd.color_temperature.and_then(|ct| ct.mirek)) + .with_color_xy(upd.color.map(|col| col.xy)) + .with_transition(transition); + + // We don't want to send gradient updates twice, but if hue + // effects are not supported for this light, this is the best + // (and only) way to do it + if !hue_effects { + payload = payload.with_gradient(upd.gradient.clone()); + } + + // handle "identify" request (light breathing) + if upd.identify.is_some() { + // update immediate payload with breathe effect + payload = payload.with_effect(DeviceEffect::Breathe); + + let tx = self.message_tx.clone(); + let topic = topic.clone(); + + // spawn task to stop effect after a few seconds + let _job = tokio::spawn(async move { + sleep(Self::LIGHT_BREATHE_DURATION).await; + + let upd = DeviceUpdate::new().with_effect(DeviceEffect::FinishEffect); + tx.send((topic, upd)) + }); + } + + z2mws.send_update(topic, &payload).await?; + + /* step 2: if supported (and needed) send hue-specific effects update */ + + if hue_effects { + let mut hz = Self::make_hue_specific_update(upd)?; + + if !hz.is_empty() { + hz = hz.with_fade_speed(0x0001); + + z2mws.send_hue_effects(topic, hz).await?; + } + } + + Ok(()) + } + + async fn backend_scene_create( + &self, + z2mws: &mut Z2mWebSocket, + link_scene: &ResourceLink, + sid: u32, + scene: &Scene, + ) -> ApiResult<()> { + let Some(topic) = self.rmap.get(&scene.group) else { + return Ok(()); + }; + + log::info!("New scene: {link_scene:?} ({})", scene.metadata.name); + + let mut lock = self.state.lock().await; + + let auxdata = AuxData::new() + .with_topic(&scene.metadata.name) + .with_index(sid); + + lock.aux_set(link_scene, auxdata); + + z2mws + .send_scene_store(topic, &scene.metadata.name, sid) + .await?; + + lock.add(link_scene, Resource::Scene(scene.clone()))?; + drop(lock); + + Ok(()) + } + + async fn backend_scene_update( + &mut self, + z2mws: &mut Z2mWebSocket, + link: &ResourceLink, + upd: &SceneUpdate, + ) -> ApiResult<()> { + let mut lock = self.state.lock().await; + + let scene = lock.get::(link)?; + + let index = lock + .aux_get(link)? + .index + .ok_or(HueError::NotFound(link.rid))?; + + if let Some(recall) = &upd.recall { + if recall.action == Some(SceneStatusEnum::Active) { + let scenes = lock.get_scenes_for_room(&scene.group.rid); + for rid in scenes { + lock.update::(&rid, |scn| { + scn.status = Some(SceneStatus { + active: if rid == link.rid { + SceneActive::Static + } else { + SceneActive::Inactive + }, + last_recall: None, + }); + })?; + } + + let room = lock.get::(link)?.group; + drop(lock); + + if let Some(topic) = self.rmap.get(&room).cloned() { + log::info!("[{}] Recall scene: {link:?}", self.name); + + let mut lock = self.state.lock().await; + self.learner.learn_scene_recall(link, &mut lock)?; + + z2mws.send_scene_recall(&topic, index).await?; + } + } else { + log::error!("Scene recall type not supported: {recall:?}"); + } + } else { + // We're not recalling the scene, so we are updating the scene + let room = lock.get::(link)?.group; + + if let Some(topic) = self.rmap.get(&room).cloned() { + log::info!("[{}] Store scene: {link:?}", self.name); + + let scene = lock.get::(link)?; + z2mws + .send_scene_store(&topic, &scene.metadata.name, index) + .await?; + + // We have requested z2m to update the scene, so update + // the state database accordingly + lock.update::(&link.rid, |scene| { + *scene += upd; + })?; + + drop(lock); + } + } + + Ok(()) + } + + async fn backend_grouped_light_update( + &self, + z2mws: &mut Z2mWebSocket, + link: &ResourceLink, + upd: &GroupedLightUpdate, + ) -> ApiResult<()> { + let room = self.state.lock().await.get::(link)?.owner; + + if let Some(topic) = self.rmap.get(&room) { + z2mws.send_update(topic, &upd.into()).await?; + } + + Ok(()) + } + + async fn backend_room_update( + &self, + z2mws: &mut Z2mWebSocket, + link: &ResourceLink, + upd: &RoomUpdate, + ) -> ApiResult<()> { + let lock = self.state.lock().await; + + if let Some(children) = &upd.children { + if let Some(topic) = self.rmap.get(link) { + let room = lock.get::(link)?.clone(); + drop(lock); + + let known_existing: BTreeSet<_> = room + .children + .iter() + .filter(|device| self.rmap.contains_key(device)) + .collect(); + + let known_new: BTreeSet<_> = children + .iter() + .filter(|device| self.rmap.contains_key(device)) + .collect(); + + for add in known_new.difference(&known_existing) { + let friendly_name = &self.rmap[add]; + z2mws.send_group_member_add(topic, friendly_name).await?; + } + + for remove in known_existing.difference(&known_new) { + let friendly_name = &self.rmap[remove]; + z2mws.send_group_member_remove(topic, friendly_name).await?; + } + } + } + + Ok(()) + } + + async fn backend_delete(&self, z2mws: &mut Z2mWebSocket, link: &ResourceLink) -> ApiResult<()> { + match link.rtype { + RType::Scene => { + let lock = self.state.lock().await; + let room = lock.get::(link)?.group; + let index = lock + .aux_get(link)? + .index + .ok_or(HueError::NotFound(link.rid))?; + drop(lock); + + if let Some(topic) = self.rmap.get(&room) { + z2mws.send_scene_remove(topic, index).await?; + } + } + + RType::Device => { + if let Some(dev) = self + .rmap + .get(link) + .and_then(|topic| self.network.get(topic)) + { + let addr = dev.ieee_address.to_string(); + log::info!( + "[{}] Requesting z2m removal of {} ({})", + self.name, + &addr, + dev.friendly_name + ); + + z2mws.send_device_remove(addr).await?; + } + } + + rtype => { + log::warn!( + "[{}] Deleting objects of type {rtype:?} is not supported", + self.name + ); + } + } + Ok(()) + } + + async fn backend_entertainment_start( + &mut self, + z2mws: &mut Z2mWebSocket, + ent_id: &Uuid, + ) -> ApiResult<()> { + log::trace!("[{}] Entertainment start", self.name); + let lock = self.state.lock().await; + + let ent: &EntertainmentConfiguration = lock.get_id(*ent_id)?; + + let mut chans = ent.channels.clone(); + + let mut addrs: BTreeMap> = BTreeMap::new(); + let mut targets = vec![]; + chans.sort_by_key(|c| c.channel_id); + + log::trace!("[{}] Resolving entertainment channels", self.name); + for chan in chans { + for member in &chan.members { + let ent: &Entertainment = lock.get(&member.service)?; + let light_id = ent + .renderer_reference + .ok_or(HueError::NotFound(member.service.rid))?; + let topic = self + .rmap + .get(&light_id) + .ok_or(HueError::NotFound(light_id.rid))?; + let dev = self + .network + .get(topic) + .ok_or(HueError::NotFound(member.service.rid))?; + + let segment_addr = dev.network_address + member.index; + + addrs + .entry(dev.friendly_name.clone()) + .or_default() + .push(segment_addr); + + targets.push(topic); + } + } + log::debug!("Entertainment addresses: {addrs:04x?}"); + drop(lock); + + if let Some(target) = targets.first() { + let mut es = EntStream::new(self.counter, target, addrs); + + // Not even a real Philips Hue bridge uses this trick! + // + // We set the entertainment mode fade speed ("smoothing") + // to fit the target frame rate, to ensure perfectly smooth + // transitionss, even at low frame rates! + es.stream.set_smoothing_duration(self.throttle.interval())?; + + log::info!("Starting entertainment mode stream at {} fps", self.fps); + + es.start_stream(z2mws).await?; + + self.entstream = Some(es); + } + + Ok(()) + } + + async fn backend_entertainment_frame( + &mut self, + z2mws: &mut Z2mWebSocket, + frame: &HueStreamLightsV2, + ) -> ApiResult<()> { + if let Some(es) = &mut self.entstream { + if self.throttle.tick() { + es.frame(z2mws, frame).await?; + } + } + + Ok(()) + } + + async fn backend_entertainment_stop(&mut self, z2mws: &mut Z2mWebSocket) -> ApiResult<()> { + log::debug!("Stopping entertainment mode.."); + if let Some(es) = &mut self.entstream.take() { + let mut lock = self.state.lock().await; + + es.stop_stream(z2mws).await?; + + self.counter = es.stream.counter(); + + for id in lock.get_resource_ids_by_type(RType::Light) { + let light: &Light = lock.get_id(id)?; + if light.is_streaming() { + lock.update(&id, Light::stop_streaming)?; + } + } + + for id in lock.get_resource_ids_by_type(RType::EntertainmentConfiguration) { + let ec: &EntertainmentConfiguration = lock.get_id(id)?; + if ec.is_streaming() { + lock.update(&id, EntertainmentConfiguration::stop_streaming)?; + } + } + drop(lock); + } + + Ok(()) + } + + async fn backend_zigbee_device_discovery( + &self, + z2mws: &mut Z2mWebSocket, + _rlink: &ResourceLink, + _zbd: &ZigbeeDeviceDiscoveryUpdate, + ) -> ApiResult<()> { + z2mws.send_permit_join(60 * 4, None).await + } + + pub async fn handle_backend_event( + &mut self, + z2mws: &mut Z2mWebSocket, + req: Arc, + ) -> ApiResult<()> { + self.learner.cleanup(); + + match &*req { + BackendRequest::LightUpdate(link, upd) => { + self.backend_light_update(z2mws, link, upd).await + } + + BackendRequest::SceneCreate(link, sid, scene) => { + self.backend_scene_create(z2mws, link, *sid, scene).await + } + + BackendRequest::SceneUpdate(link, upd) => { + self.backend_scene_update(z2mws, link, upd).await + } + + BackendRequest::GroupedLightUpdate(link, upd) => { + self.backend_grouped_light_update(z2mws, link, upd).await + } + + BackendRequest::RoomUpdate(link, upd) => { + self.backend_room_update(z2mws, link, upd).await + } + + BackendRequest::Delete(link) => self.backend_delete(z2mws, link).await, + + BackendRequest::EntertainmentStart(ent_id) => { + self.backend_entertainment_start(z2mws, ent_id).await + } + + BackendRequest::EntertainmentFrame(frame) => { + self.backend_entertainment_frame(z2mws, frame).await + } + + BackendRequest::EntertainmentStop() => self.backend_entertainment_stop(z2mws).await, + + BackendRequest::ZigbeeDeviceDiscovery(rlink, zbd) => { + self.backend_zigbee_device_discovery(z2mws, rlink, zbd) + .await + } + } + } +} diff --git a/src/backend/z2m/bridge_event.rs b/src/backend/z2m/bridge_event.rs new file mode 100644 index 0000000..a2a4511 --- /dev/null +++ b/src/backend/z2m/bridge_event.rs @@ -0,0 +1,307 @@ +use serde::Deserialize; +use serde_json::Value; +use tokio_tungstenite::tungstenite; +use uuid::Uuid; + +use hue::api::{DimmingUpdate, GroupedLight, Light, LightUpdate, RType, Resource, Room}; +use z2m::api::{ + BridgeDevices, DeviceRemoveResponse, GroupMemberChange, Message, RawMessage, Response, +}; +use z2m::update::DeviceUpdate; + +use crate::backend::z2m::Z2mBackend; +use crate::error::{ApiError, ApiResult}; + +impl Z2mBackend { + async fn handle_update_light(&mut self, uuid: &Uuid, devupd: &DeviceUpdate) -> ApiResult<()> { + let upd: LightUpdate = devupd.into(); + + let mut lock = self.state.lock().await; + lock.update::(uuid, |light| *light += &upd)?; + + self.learner.learn(uuid, &lock, devupd)?; + self.learner.collect(&mut lock)?; + drop(lock); + + Ok(()) + } + + async fn handle_update_grouped_light(&self, uuid: &Uuid, upd: &DeviceUpdate) -> ApiResult<()> { + let mut res = self.state.lock().await; + res.update::(uuid, |glight| { + if let Some(state) = &upd.state { + glight.on = Some((*state).into()); + } + + if let Some(b) = upd.brightness { + glight.dimming = Some(DimmingUpdate { + brightness: b / 254.0 * 100.0, + }); + } + }) + } + + async fn handle_update(&mut self, rid: &Uuid, payload: &Value) -> ApiResult<()> { + if let Value::String(string) = payload { + if string.is_empty() { + log::debug!("Ignoring empty payload for {rid}"); + return Ok(()); + } + } + + let upd = DeviceUpdate::deserialize(payload)?; + + let obj = self.state.lock().await.get_resource_by_id(rid)?.obj; + match obj { + Resource::Light(_) => { + if let Err(e) = self.handle_update_light(rid, &upd).await { + log::error!("FAIL: {e:?} in {upd:?}"); + } + } + Resource::GroupedLight(_) => { + if let Err(e) = self.handle_update_grouped_light(rid, &upd).await { + log::error!("FAIL: {e:?} in {upd:?}"); + } + } + _ => {} + } + + Ok(()) + } + + async fn handle_device_message(&mut self, msg: RawMessage) -> ApiResult<()> { + if msg.topic.ends_with("/availability") || msg.topic.ends_with("/action") { + // availability: https://www.zigbee2mqtt.io/guide/usage/mqtt_topics_and_messages.html#zigbee2mqtt-friendly-name-availability + // action: https://www.home-assistant.io/integrations/device_trigger.mqtt/ + return Ok(()); + } + + let Some(ref val) = self.map.get(&msg.topic).copied() else { + if !self.ignore.contains(&msg.topic) { + log::warn!( + "[{}] Notification on unknown topic {}", + self.name, + &msg.topic + ); + } + return Ok(()); + }; + + let res = self.handle_update(&val.rid, &msg.payload).await; + if let Err(ref err) = res { + log::error!( + "Cannot parse update: {err}\n{}", + serde_json::to_string_pretty(&msg.payload)? + ); + } + + /* return Ok here, since we do not want to break the event loop */ + Ok(()) + } + + async fn bridge_devices(&mut self, devices: &BridgeDevices) -> ApiResult<()> { + for dev in devices { + self.network.insert(dev.friendly_name.clone(), dev.clone()); + if let Some(exp) = dev.expose_light() { + log::info!( + "[{}] Adding light {:?}: [{}] ({})", + self.name, + dev.ieee_address, + dev.friendly_name, + dev.model_id.as_deref().unwrap_or("") + ); + self.add_light(dev, exp).await?; + } else { + log::debug!( + "[{}] Ignoring unsupported device {}", + self.name, + dev.friendly_name + ); + self.ignore.insert(dev.friendly_name.to_string()); + } + /* + if dev.expose_action() { + log::info!( + "[{}] Adding switch {:?}: [{}] ({})", + self.name, + dev.ieee_address, + dev.friendly_name, + dev.model_id.as_deref().unwrap_or("") + ); + self.add_switch(dev).await?; + } + */ + } + + Ok(()) + } + + async fn bridge_device_remove(&mut self, data: &DeviceRemoveResponse) -> ApiResult<()> { + if let Some(rlink) = self.map.get(&data.id) { + match rlink.rtype { + RType::Light => { + let mut lock = self.state.lock().await; + let owner = lock.get::(rlink)?.owner; + log::info!("Removing device: {owner:?}"); + lock.delete(&owner)?; + } + rtype => { + log::warn!("Cannot handle removing resource of type {rtype:?}"); + } + } + } + + if let Some(_rlink) = self.map.remove(&data.id) { + self.rmap.retain(|_, v| *v != data.id); + } + + Ok(()) + } + + #[allow(clippy::collapsible_else_if)] + async fn bridge_group_member_change( + &self, + change: &GroupMemberChange, + added: bool, + ) -> ApiResult<()> { + if let Some(light) = self.map.get(&change.device) { + let mut lock = self.state.lock().await; + let device = lock.get::(light)?.clone(); + + let device_link = device.owner; + if let Some(room) = self.map.get(&change.group) { + let room_link = lock.get::(room)?.owner; + let exists = lock + .get::(&room_link)? + .children + .contains(&device_link); + + if added { + if !exists { + lock.update(&room_link.rid, |room: &mut Room| { + room.children.insert(device_link); + })?; + } + } else { + if exists { + lock.update(&room_link.rid, |room: &mut Room| { + room.children.remove(&device_link); + })?; + } + } + } + } + + Ok(()) + } + + async fn handle_bridge_message(&mut self, msg: Message) -> ApiResult<()> { + #[allow(unused_variables)] + match &msg { + Message::BridgeInfo(obj) => { /* println!("{obj:#?}"); */ } + Message::BridgeLogging(obj) => { /* println!("{obj:#?}"); */ } + Message::BridgeExtensions(obj) => { /* println!("{obj:#?}"); */ } + Message::BridgeEvent(obj) => { /* println!("{obj:#?}"); */ } + Message::BridgeDefinitions(obj) => { /* println!("{obj:#?}"); */ } + Message::BridgeState(obj) => { /* println!("{obj:#?}"); */ } + Message::BridgeConverters(obj) => { /* println!("{obj:#?}"); */ } + Message::BridgeOptions(obj) => { /* println!("{obj:#?}"); */ } + Message::BridgePermitJoin(obj) => {} + Message::BridgeTouchlinkScan(obj) => {} + Message::BridgeDeviceOptions(obj) => {} + Message::BridgeNetworkmap(obj) => {} + Message::BridgeDeviceOtaUpdateCheck(obj) => {} + Message::BridgeDeviceConfigureReporting(obj) => {} + Message::BridgeConfig(obj) => {} + Message::BridgeResponseGroupAdd(obj) => {} + Message::BridgeResponseGroupRemove(obj) => {} + Message::BridgeResponseGroupRename(obj) => {} + Message::BridgeResponseGroupOptions(obj) => {} + + Message::BridgeDevices(obj) => { + self.bridge_devices(obj).await?; + } + + Message::BridgeGroups(obj) => { + /* println!("{obj:#?}"); */ + for grp in obj { + self.add_group(grp).await?; + } + } + + Message::BridgeGroupMembersAdd(change) | Message::BridgeGroupMembersRemove(change) => { + let Response::Ok { data: change, .. } = change else { + log::warn!("[{}] Error reported from z2m: {change:?}", self.name); + return Ok(()); + }; + + let added = matches!(msg, Message::BridgeGroupMembersAdd(_)); + self.bridge_group_member_change(change, added).await?; + } + + Message::BridgeDeviceRemove(obj) => { + let Response::Ok { data, .. } = obj else { + log::warn!("[{}] Error reported from z2m: {obj:?}", self.name); + return Ok(()); + }; + + self.bridge_device_remove(data).await?; + } + } + Ok(()) + } + + pub async fn handle_bridge_event(&mut self, pkt: tungstenite::Message) -> ApiResult<()> { + let tungstenite::Message::Text(txt) = pkt else { + log::error!("[{}] Received non-text message on websocket :(", self.name); + return Err(ApiError::UnexpectedZ2mReply(pkt)); + }; + + let raw_msg = serde_json::from_str::(&txt); + + log::trace!("[{}] Incoming z2m message: {txt}", self.name); + + let msg = raw_msg.map_err(|err| { + log::error!( + "[{}] Invalid websocket message: {:#?} [{}..]", + self.name, + err, + &txt.chars().take(128).collect::() + ); + err + })?; + + /* bridge messages are handled differently. everything else is a device message */ + if !msg.topic.starts_with("bridge/") { + return self.handle_device_message(msg).await; + } + + match serde_json::from_str(&txt) { + Ok(bridge_msg) => self.handle_bridge_message(bridge_msg).await, + Err(err) => { + match msg.topic.as_str() { + topic @ ("bridge/devices" | "bridge/groups") => { + log::error!( + "[{}] Failed to parse critical z2m bridge message on [{}]:", + self.name, + topic, + ); + log::error!("[{}] {}", self.name, serde_json::to_string(&msg.payload)?); + Err(err)? + } + topic => { + log::error!( + "[{}] Failed to parse (non-critical) z2m bridge message on [{}]:", + self.name, + topic + ); + log::error!("{}", serde_json::to_string(&msg.payload)?); + + /* Suppress this non-critical error, to avoid breaking the event loop */ + Ok(()) + } + } + } + } + } +} diff --git a/src/backend/z2m/bridge_import.rs b/src/backend/z2m/bridge_import.rs new file mode 100644 index 0000000..cd2acb1 --- /dev/null +++ b/src/backend/z2m/bridge_import.rs @@ -0,0 +1,367 @@ +use std::collections::HashSet; + +use chrono::Utc; +use maplit::btreeset; +use serde_json::json; +use uuid::Uuid; + +use hue::api::{ + BridgeHome, Button, ButtonData, ButtonMetadata, ButtonReport, DeviceArchetype, + DeviceProductData, Entertainment, EntertainmentSegment, EntertainmentSegments, GroupedLight, + Light, LightEffects, LightEffectsV2, LightMetadata, Metadata, RType, Resource, ResourceLink, + Room, RoomArchetype, RoomMetadata, Scene, SceneActive, SceneMetadata, SceneRecall, SceneStatus, + Stub, Taurus, ZigbeeConnectivity, ZigbeeConnectivityStatus, +}; +use hue::scene_icons; +use z2m::api::ExposeLight; +use z2m::convert::{ + ExtractColorTemperature, ExtractDeviceProductData, ExtractDimming, ExtractLightColor, + ExtractLightGradient, +}; + +use crate::backend::z2m::Z2mBackend; +use crate::error::ApiResult; +use crate::model::state::AuxData; + +impl Z2mBackend { + pub async fn add_light( + &mut self, + apidev: &z2m::api::Device, + expose: &ExposeLight, + ) -> ApiResult<()> { + let name = &apidev.friendly_name; + + let link_device = RType::Device.deterministic(&apidev.ieee_address); + let link_light = RType::Light.deterministic(&apidev.ieee_address); + let link_enttm = RType::Entertainment.deterministic(&apidev.ieee_address); + let link_taurus = RType::Taurus.deterministic(&apidev.ieee_address); + let link_zigcon = RType::ZigbeeConnectivity.deterministic(&apidev.ieee_address); + + let product_data = DeviceProductData::guess_from_device(apidev); + let metadata = LightMetadata::new(product_data.product_archetype.clone(), name); + + let effects = + apidev.manufacturer.as_deref() == Some(DeviceProductData::SIGNIFY_MANUFACTURER_NAME); + let gradient = apidev.expose_gradient(); + + let dev = hue::api::Device { + product_data, + metadata: metadata.clone().into(), + services: btreeset![link_zigcon, link_light, link_enttm, link_taurus], + identify: Some(Stub), + usertest: None, + }; + + self.map.insert(name.to_string(), link_light); + self.rmap.insert(link_device, name.to_string()); + self.rmap.insert(link_light, name.to_string()); + + let mut light = Light::new(link_device, metadata); + + light.dimming = expose + .feature("brightness") + .and_then(ExtractDimming::extract_from_expose); + log::trace!("Detected dimming: {:?}", &light.dimming); + + light.color_temperature = expose + .feature("color_temp") + .and_then(ExtractColorTemperature::extract_from_expose); + log::trace!("Detected color temperature: {:?}", &light.color_temperature); + + light.color = expose + .feature("color_xy") + .and_then(ExtractLightColor::extract_from_expose); + log::trace!("Detected color: {:?}", &light.color); + + light.gradient = gradient.and_then(ExtractLightGradient::extract_from_expose); + log::trace!("Detected gradient support: {:?}", &light.gradient); + + if effects { + log::trace!("Detected Hue light: enabling effects"); + light.effects = Some(LightEffects::all()); + light.effects_v2 = Some(LightEffectsV2::all()); + } + + let segments = if gradient.is_some() { + EntertainmentSegments { + configurable: false, + max_segments: 10, + segments: (0..7) + .map(|x| EntertainmentSegment { + start: x, + length: 1, + }) + .collect(), + } + } else { + EntertainmentSegments { + configurable: false, + max_segments: 1, + segments: vec![EntertainmentSegment { + start: 0, + length: 1, + }], + } + }; + + // FIXME: This should be feature-detected, not always enabled + let enttm = Entertainment { + equalizer: true, + owner: link_device, + proxy: true, + renderer: true, + max_streams: None, + renderer_reference: Some(link_light), + segments: Some(segments), + }; + + // FIXME: The Taurus objects are seen on Hue Entertainment devices on a + // real hue bridge, but nobody knows what it does. Some clients seem to + // want them present, though. + let taurus = Taurus { + capabilities: vec![ + "sensor".to_string(), + "collector".to_string(), + "sync".to_string(), + ], + owner: link_device, + }; + + let zigcon = ZigbeeConnectivity { + channel: None, + extended_pan_id: None, + mac_address: apidev.ieee_address.to_string(), + owner: link_device, + status: ZigbeeConnectivityStatus::Connected, + }; + + let mut res = self.state.lock().await; + res.aux_set(&link_light, AuxData::new().with_topic(name)); + res.add(&link_device, Resource::Device(dev))?; + res.add(&link_light, Resource::Light(light))?; + res.add(&link_enttm, Resource::Entertainment(enttm))?; + res.add(&link_taurus, Resource::Taurus(taurus))?; + res.add(&link_zigcon, Resource::ZigbeeConnectivity(zigcon))?; + drop(res); + + Ok(()) + } + + pub async fn add_switch(&mut self, dev: &z2m::api::Device) -> ApiResult<()> { + let name = &dev.friendly_name; + + let link_device = RType::Device.deterministic(&dev.ieee_address); + let link_button = RType::Button.deterministic(&dev.ieee_address); + let link_zbc = RType::ZigbeeConnectivity.deterministic(&dev.ieee_address); + + let dev = hue::api::Device { + product_data: DeviceProductData::guess_from_device(dev), + metadata: Metadata::new(DeviceArchetype::UnknownArchetype, "foo"), + services: btreeset![link_button, link_zbc], + identify: None, + usertest: None, + }; + + self.map.insert(name.to_string(), link_button); + self.rmap.insert(link_button, name.to_string()); + + let mut res = self.state.lock().await; + let button = Button { + owner: link_device, + metadata: ButtonMetadata { control_id: 0 }, + button: ButtonData { + last_event: None, + button_report: Some(ButtonReport { + updated: Utc::now(), + event: String::from("initial_press"), + }), + repeat_interval: Some(100), + event_values: Some(json!(["initial_press", "repeat"])), + }, + }; + + let zbc = ZigbeeConnectivity { + owner: link_device, + mac_address: String::from("11:22:33:44:55:66:77:89"), + status: ZigbeeConnectivityStatus::ConnectivityIssue, + channel: Some(json!({ + "status": "set", + "value": "channel_25", + })), + extended_pan_id: None, + }; + + res.add(&link_device, Resource::Device(dev))?; + res.add(&link_button, Resource::Button(button))?; + res.add(&link_zbc, Resource::ZigbeeConnectivity(zbc))?; + drop(res); + + Ok(()) + } + + #[allow(clippy::too_many_lines)] + pub async fn add_group(&mut self, grp: &z2m::api::Group) -> ApiResult<()> { + let room_name; + + if let Some(ref prefix) = self.server.group_prefix { + if let Some(name) = grp.friendly_name.strip_prefix(prefix) { + room_name = name; + } else { + log::debug!( + "[{}] Ignoring room outside our prefix: {}", + self.name, + grp.friendly_name + ); + return Ok(()); + } + } else { + room_name = &grp.friendly_name; + } + + let link_room = RType::Room.deterministic(&grp.friendly_name); + let link_glight = RType::GroupedLight.deterministic((link_room.rid, grp.id)); + + let children = grp + .members + .iter() + .map(|f| RType::Device.deterministic(&f.ieee_address)) + .collect(); + + let topic = grp.friendly_name.to_string(); + + let mut res = self.state.lock().await; + + let mut scenes_new = HashSet::new(); + + for scn in &grp.scenes { + let scene = Scene { + actions: vec![], + auto_dynamic: false, + group: link_room, + metadata: SceneMetadata { + appdata: None, + image: guess_scene_icon(&scn.name), + name: scn.name.to_string(), + }, + palette: json!({ + "color": [], + "dimming": [], + "color_temperature": [], + "effects": [], + }), + speed: 0.5, + recall: SceneRecall { + action: None, + dimming: None, + duration: None, + }, + status: Some(SceneStatus { + active: SceneActive::Inactive, + last_recall: None, + }), + }; + + let link_scene = RType::Scene.deterministic((link_room.rid, scn.id)); + + res.aux_set( + &link_scene, + AuxData::new().with_topic(&topic).with_index(scn.id), + ); + + scenes_new.insert(link_scene.rid); + res.add(&link_scene, Resource::Scene(scene))?; + } + + if let Ok(room) = res.get::(&link_room) { + log::info!( + "[{}] {link_room:?} ({}) known, updating..", + self.name, + room.metadata.name + ); + + let scenes_old: HashSet = + HashSet::from_iter(res.get_scenes_for_room(&link_room.rid)); + + log::trace!("[{}] old scenes: {scenes_old:?}", self.name); + log::trace!("[{}] new scenes: {scenes_new:?}", self.name); + let gone = scenes_old.difference(&scenes_new); + log::trace!("[{}] deleted: {gone:?}", self.name); + for uuid in gone { + log::debug!( + "[{}] Deleting orphaned {uuid:?} in {link_room:?}", + self.name + ); + let _ = res.delete(&RType::Scene.link_to(*uuid)); + } + } else { + log::debug!( + "[{}] {link_room:?} ({}) is new, adding..", + self.name, + room_name + ); + } + + let mut metadata = RoomMetadata::new(RoomArchetype::Home, room_name); + if let Some(room_conf) = self.config.rooms.get(&topic) { + if let Some(name) = &room_conf.name { + metadata.name = name.to_string(); + } + if let Some(icon) = &room_conf.icon { + metadata.archetype = *icon; + } + } + + let room = Room { + children, + metadata, + services: btreeset![link_glight], + }; + + self.map.insert(topic.clone(), link_glight); + self.rmap.insert(link_glight, topic.clone()); + self.rmap.insert(link_room, topic.clone()); + + for id in &res.get_resource_ids_by_type(RType::BridgeHome) { + res.update(id, |bh: &mut BridgeHome| { + bh.children.insert(link_room); + })?; + } + + res.add(&link_room, Resource::Room(room))?; + + let glight = GroupedLight::new(link_room); + + res.add(&link_glight, Resource::GroupedLight(glight))?; + drop(res); + + Ok(()) + } +} + +#[allow(clippy::match_same_arms)] +fn guess_scene_icon(name: &str) -> Option { + let icon = match name { + /* Built-in names */ + "Bright" => scene_icons::BRIGHT, + "Relax" => scene_icons::RELAX, + "Night Light" => scene_icons::NIGHT_LIGHT, + "Rest" => scene_icons::REST, + "Concentrate" => scene_icons::CONCENTRATE, + "Dimmed" => scene_icons::DIMMED, + "Energize" => scene_icons::ENERGIZE, + "Read" => scene_icons::READ, + "Cool Bright" => scene_icons::COOL_BRIGHT, + + /* Aliases */ + "Night" => scene_icons::NIGHT_LIGHT, + "Cool" => scene_icons::COOL_BRIGHT, + "Dim" => scene_icons::DIMMED, + + _ => return None, + }; + + Some(ResourceLink { + rid: icon, + rtype: RType::PublicImage, + }) +} diff --git a/src/backend/z2m/entertainment.rs b/src/backend/z2m/entertainment.rs new file mode 100644 index 0000000..0020341 --- /dev/null +++ b/src/backend/z2m/entertainment.rs @@ -0,0 +1,142 @@ +use std::collections::BTreeMap; + +use serde_json::json; + +use hue::stream::HueStreamLightsV2; +use hue::zigbee::{ + EntertainmentZigbeeStream, HueEntFrameLightRecord, LightRecordMode, + PHILIPS_HUE_ZIGBEE_VENDOR_ID, +}; +use z2m::request::Z2mRequest; +use zcl::attr::ZclDataType; + +use crate::backend::z2m::websocket::Z2mWebSocket; +use crate::error::ApiResult; + +pub struct EntStream { + pub stream: EntertainmentZigbeeStream, + pub target: String, + pub addrs: BTreeMap>, + pub modes: Vec<(u16, LightRecordMode)>, +} + +impl EntStream { + #[must_use] + pub fn new(counter: u32, target: &str, addrs: BTreeMap>) -> Self { + let modes = Self::addrs_to_light_modes(&addrs); + Self { + stream: EntertainmentZigbeeStream::new(counter), + target: target.to_string(), + addrs, + modes, + } + } + + #[must_use] + pub fn addrs_to_light_modes(addrs: &BTreeMap>) -> Vec<(u16, LightRecordMode)> { + let mut modes = vec![]; + + for segments in addrs.values() { + let mode = if segments.len() <= 1 { + LightRecordMode::Device + } else { + LightRecordMode::Segment + }; + + for seg in segments { + modes.push((*seg, mode)); + } + } + + modes + } + + #[must_use] + pub fn z2m_set_entertainment_brightness(brightness: u8) -> Z2mRequest<'static> { + Z2mRequest::Write { + cluster: EntertainmentZigbeeStream::CLUSTER, + payload: json!({ + EntertainmentZigbeeStream::CMD_LIGHT_BALANCE.to_string(): { + "manufacturerCode": PHILIPS_HUE_ZIGBEE_VENDOR_ID, + "type": ZclDataType::ZclU8 as u8, + "value": brightness, + } + }), + } + } + + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + #[must_use] + pub fn generate_frame(&self, frame: &HueStreamLightsV2) -> Vec { + let mut blks = vec![]; + match frame { + HueStreamLightsV2::Rgb(rgb) => { + for light in rgb { + let (xy, bright) = light.rgb.to_xy(); + + let brightness = (bright / 255.0 * 2047.0).clamp(1.0, 2047.0) as u16; + let (chan, mode) = self.modes[light.channel as usize % self.modes.len()]; + let raw = xy.to_quant(); + let lrec = HueEntFrameLightRecord::new(chan, brightness, mode, raw); + + blks.push(lrec); + } + } + HueStreamLightsV2::Xy(xy) => { + for light in xy { + let (xy, bright) = light.xy.to_xy(); + + let brightness = (bright / 255.0 * 2047.0).clamp(1.0, 2047.0) as u16; + let (chan, mode) = self.modes[light.channel as usize % self.modes.len()]; + let raw = xy.to_quant(); + let lrec = HueEntFrameLightRecord::new(chan, brightness, mode, raw); + + blks.push(lrec); + } + } + } + + blks + } + + pub async fn start_stream(&mut self, z2mws: &mut Z2mWebSocket) -> ApiResult<()> { + log::debug!("Entertainment addrs: {:#?}", &self.addrs); + log::debug!("Entertainment modes: {:#?}", &self.modes); + for (dev, segments) in &self.addrs { + let z2mreq = Self::z2m_set_entertainment_brightness(0xFE); + z2mws.send(dev, &z2mreq).await?; + + if segments.len() <= 1 { + continue; + } + + let mapping = self.stream.segment_mapping(segments)?; + z2mws.send_zigbee_message(dev, &mapping).await?; + } + + self.stop_stream(z2mws).await?; + + Ok(()) + } + + pub async fn stop_stream(&mut self, z2mws: &mut Z2mWebSocket) -> ApiResult<()> { + let stop = self.stream.reset()?; + for topic in self.addrs.keys() { + log::debug!("Sending stop to {topic}"); + z2mws.send_zigbee_message(topic, &stop).await?; + } + + Ok(()) + } + + pub async fn frame( + &mut self, + z2mws: &mut Z2mWebSocket, + frame: &HueStreamLightsV2, + ) -> ApiResult<()> { + let blks = self.generate_frame(frame); + + let message = self.stream.frame(blks)?; + z2mws.send_entertainment_frame(&self.target, &message).await + } +} diff --git a/src/backend/z2m/learn.rs b/src/backend/z2m/learn.rs new file mode 100644 index 0000000..868fcdd --- /dev/null +++ b/src/backend/z2m/learn.rs @@ -0,0 +1,155 @@ +use std::collections::{HashMap, HashSet}; + +use chrono::{DateTime, Duration, Utc}; +use serde_json::json; +use uuid::Uuid; + +use hue::api::{ + ColorTemperatureUpdate, ColorUpdate, Light, LightGradientPoint, LightGradientUpdate, RType, + ResourceLink, Room, Scene, SceneAction, SceneActionElement, +}; +use z2m::hexcolor::HexColor; +use z2m::update::{DeviceColor, DeviceUpdate}; + +use crate::error::ApiResult; +use crate::resource::Resources; + +pub struct SceneLearn { + name: String, + scenes: HashMap, +} + +#[derive(Debug)] +struct SceneInfo { + pub expire: DateTime, + pub missing: HashSet, + pub known: HashMap, +} + +impl SceneLearn { + #[must_use] + pub fn new(name: String) -> Self { + Self { + name, + scenes: HashMap::new(), + } + } + + pub fn cleanup(&mut self) { + let now = Utc::now(); + self.scenes.retain(|uuid, lscene| { + let expired = lscene.expire < now; + if expired { + log::warn!( + "[{}] Failed to learn scene {uuid} before deadline", + self.name + ); + } + !expired + }); + } + + pub fn learn_scene_recall( + &mut self, + lscene: &ResourceLink, + lock: &mut Resources, + ) -> ApiResult<()> { + let scene: &Scene = lock.get(lscene)?; + + if !scene.actions.is_empty() { + return Ok(()); + } + + let room: &Room = lock.get(&scene.group)?; + + let lights: Vec = room + .children + .iter() + .filter_map(|rl| lock.get(rl).ok()) + .filter_map(hue::api::Device::light_service) + .map(|rl| rl.rid) + .collect(); + + let learn = SceneInfo { + expire: Utc::now() + Duration::seconds(5), + missing: HashSet::from_iter(lights), + known: HashMap::new(), + }; + + self.scenes.insert(lscene.rid, learn); + + Ok(()) + } + + #[allow(clippy::option_if_let_else, clippy::manual_map)] + pub fn learn(&mut self, uuid: &Uuid, res: &Resources, upd: &DeviceUpdate) -> ApiResult<()> { + for learn in self.scenes.values_mut() { + if !learn.missing.remove(uuid) { + continue; + } + + let rlink = RType::Light.link_to(*uuid); + let light = res.get::(&rlink)?; + let mut color_temperature = None; + let mut color = None; + if let Some(DeviceColor { xy: Some(xy), .. }) = upd.color { + color = Some(ColorUpdate { xy }); + } else if let Some(mirek) = upd.color_temp { + color_temperature = Some(ColorTemperatureUpdate::new(mirek)); + } + + let gradient = if let Some(grad) = &upd.gradient { + Some(LightGradientUpdate { + mode: None, + points: grad + .iter() + .map(|p| LightGradientPoint { + color: ColorUpdate { + xy: HexColor::to_xy_color(p), + }, + }) + .collect(), + }) + } else { + None + }; + + learn.known.insert( + *uuid, + SceneAction { + color, + color_temperature, + dimming: light.as_dimming_opt(), + on: Some(light.on), + gradient, + effects: json!({}), + }, + ); + + log::info!("[{}] Learn: {learn:?}", self.name); + } + + Ok(()) + } + + pub fn collect(&mut self, res: &mut Resources) -> ApiResult<()> { + let keys: Vec = self.scenes.keys().copied().collect(); + for uuid in &keys { + if self.scenes[uuid].missing.is_empty() { + let lscene = self.scenes.remove(uuid).unwrap(); + log::info!("[{}] Learned all lights {uuid}", self.name); + let actions: Vec = lscene + .known + .into_iter() + .map(|(uuid, action)| SceneActionElement { + action, + target: RType::Light.link_to(uuid), + }) + .collect(); + res.update::(uuid, |scene| scene.actions = actions)?; + } + } + + Ok(()) + } +} diff --git a/src/backend/z2m/mod.rs b/src/backend/z2m/mod.rs new file mode 100644 index 0000000..cefd96d --- /dev/null +++ b/src/backend/z2m/mod.rs @@ -0,0 +1,226 @@ +mod backend_event; +mod bridge_event; +mod bridge_import; +pub mod entertainment; +pub mod learn; +pub mod websocket; +pub mod zclcommand; + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use futures::StreamExt; +use native_tls::TlsConnector; +use svc::error::SvcError; +use svc::template::ServiceTemplate; +use svc::traits::{BoxDynService, Service}; +use thiserror::Error; +use tokio::net::TcpStream; +use tokio::select; +use tokio::sync::broadcast::Receiver; +use tokio::sync::{Mutex, mpsc}; +use tokio_tungstenite::{ + Connector, MaybeTlsStream, WebSocketStream, connect_async_tls_with_config, +}; + +use bifrost_api::backend::BackendRequest; +use hue::api::ResourceLink; +use z2m::update::DeviceUpdate; + +use crate::backend::z2m::entertainment::EntStream; +use crate::backend::z2m::learn::SceneLearn; +use crate::backend::z2m::websocket::Z2mWebSocket; +use crate::config::{AppConfig, Z2mServer}; +use crate::error::{ApiError, ApiResult}; +use crate::model::throttle::Throttle; +use crate::resource::Resources; +use crate::server::appstate::AppState; + +#[derive(Error, Debug)] +pub enum TemplateError { + #[error("No config found for z2m server {0:?}")] + NotFound(String), +} + +pub struct Z2mServiceTemplate { + state: AppState, +} + +impl Z2mServiceTemplate { + #[must_use] + pub const fn new(state: AppState) -> Self { + Self { state } + } +} + +impl ServiceTemplate for Z2mServiceTemplate { + fn generate(&self, name: String) -> Result { + let config = self.state.config(); + let Some(server) = config.z2m.servers.get(&name) else { + return Err(SvcError::generation(TemplateError::NotFound(name))); + }; + let svc = Z2mBackend::new(name, server.clone(), config, self.state.res.clone()) + .map_err(SvcError::generation)?; + + Ok(svc.boxed()) + } +} + +pub struct Z2mBackend { + name: String, + server: Z2mServer, + config: Arc, + state: Arc>, + map: HashMap, + rmap: HashMap, + learner: SceneLearn, + ignore: HashSet, + network: HashMap, + entstream: Option, + counter: u32, + fps: u32, + throttle: Throttle, + socket: Option>>, + + // for sending delayed messages over the websocket + message_rx: mpsc::UnboundedReceiver<(String, DeviceUpdate)>, + message_tx: mpsc::UnboundedSender<(String, DeviceUpdate)>, +} + +impl Z2mBackend { + const DEFAULT_FPS: u32 = 20; + const LIGHT_BREATHE_DURATION: Duration = Duration::from_secs(2); + + pub fn new( + name: String, + server: Z2mServer, + config: Arc, + state: Arc>, + ) -> ApiResult { + let fps = server.streaming_fps.map_or(Self::DEFAULT_FPS, u32::from); + let map = HashMap::new(); + let rmap = HashMap::new(); + let ignore = HashSet::new(); + let learner = SceneLearn::new(name.clone()); + let network = HashMap::new(); + let entstream = None; + let throttle = Throttle::from_fps(fps); + let (message_tx, message_rx) = mpsc::unbounded_channel(); + Ok(Self { + name, + server, + config, + state, + map, + rmap, + learner, + ignore, + network, + entstream, + throttle, + fps, + message_rx, + message_tx, + socket: None, + counter: 0, + }) + } + + pub async fn event_loop( + &mut self, + chan: &mut Receiver>, + mut socket: Z2mWebSocket, + ) -> ApiResult<()> { + loop { + select! { + // all backend event handling implemented in backend::z2m::backend_event + pkt = chan.recv() => { + let api_req = pkt?; + self.handle_backend_event(&mut socket, api_req).await?; + // FIXME: this used to be our "throttle" feature, but it breaks entertainment mode + /* tokio::time::sleep(std::time::Duration::from_millis(100)).await; */ + }, + + // all bridge event handling implemented in backend::z2m::bridge_event + pkt = socket.next() => { + self.handle_bridge_event(pkt.ok_or(ApiError::UnexpectedZ2mEof)??).await?; + }, + + Some((topic, upd)) = self.message_rx.recv() => { + socket.send_update(&topic, &upd).await?; + } + }; + } + } +} + +#[async_trait] +impl Service for Z2mBackend { + type Error = ApiError; + + async fn start(&mut self) -> ApiResult<()> { + // let's not include auth tokens in log output + let sanitized_url = self.server.get_sanitized_url(); + let url = self.server.get_url(); + + if url != self.server.url { + log::info!( + "[{}] Rewrote url for compatibility with z2m 2.x.", + self.name + ); + log::info!( + "[{}] Consider updating websocket url to {}", + self.name, + sanitized_url + ); + } + + // if tls verification is disabled, build a TlsConnector that explicitly + // does not check certificate validity. This is obviously neither safe + // nor recommended. + let connector = if self.server.disable_tls_verify.unwrap_or_default() { + log::warn!( + "[{}] TLS verification disabled; will accept any certificate!", + self.name + ); + Some(Connector::NativeTls( + TlsConnector::builder() + .danger_accept_invalid_certs(true) + .build()?, + )) + } else { + None + }; + + log::info!("[{}] Connecting to {}", self.name, &sanitized_url); + match connect_async_tls_with_config(url.as_str(), None, false, connector.clone()).await { + Ok((socket, _)) => { + self.socket = Some(socket); + Ok(()) + } + Err(err) => { + log::error!("[{}] Connect failed: {err:?}", self.name); + Err(err.into()) + } + } + } + + async fn run(&mut self) -> ApiResult<()> { + if let Some(socket) = self.socket.take() { + let z2m_socket = Z2mWebSocket::new(self.name.clone(), socket); + let mut chan = self.state.lock().await.backend_event_stream(); + let res = self.event_loop(&mut chan, z2m_socket).await; + if let Err(err) = res { + log::error!("[{}] Event loop broke: {err}", self.name); + } + } + Ok(()) + } + + async fn stop(&mut self) -> ApiResult<()> { + self.socket.take(); + Ok(()) + } +} diff --git a/src/backend/z2m/websocket.rs b/src/backend/z2m/websocket.rs new file mode 100644 index 0000000..e8449c1 --- /dev/null +++ b/src/backend/z2m/websocket.rs @@ -0,0 +1,180 @@ +use std::pin::Pin; +use std::task::{Context, Poll}; + +use futures::{SinkExt, Stream}; +use hue::zigbee::{HueZigbeeUpdate, ZigbeeMessage}; +use tokio::net::TcpStream; +use tokio_tungstenite::tungstenite::{self, Message}; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; +use z2m::api::{DeviceRemove, GroupMemberChange, PermitJoin}; +use z2m::request::Z2mPayload; +use z2m::update::DeviceUpdate; +use z2m::{api::RawMessage, request::Z2mRequest}; + +use crate::backend::z2m::zclcommand::hue_zclcommand; +use crate::error::ApiResult; + +pub struct Z2mWebSocket { + pub name: String, + pub socket: WebSocketStream>, +} + +impl Z2mWebSocket { + pub const fn new(name: String, socket: WebSocketStream>) -> Self { + Self { name, socket } + } + + pub async fn send(&mut self, topic: &str, payload: &Z2mRequest<'_>) -> ApiResult<()> { + /* let Some(link) = self.map.get(topic) else { */ + /* log::trace!( */ + /* "[{}] Topic [{topic}] unknown on this z2m connection", */ + /* self.name */ + /* ); */ + /* return Ok(()); */ + /* }; */ + + /* log::trace!( */ + /* "[{}] Topic [{topic}] known as {link:?} on this z2m connection, sending event..", */ + /* self.name */ + /* ); */ + + let api_req = match &payload { + Z2mRequest::GroupMemberAdd(value) => RawMessage { + topic: "bridge/request/group/members/add".into(), + payload: serde_json::to_value(value)?, + }, + Z2mRequest::GroupMemberRemove(value) => RawMessage { + topic: "bridge/request/group/members/remove".into(), + payload: serde_json::to_value(value)?, + }, + Z2mRequest::PermitJoin(value) => RawMessage { + topic: "bridge/request/permit_join".into(), + payload: serde_json::to_value(value)?, + }, + Z2mRequest::DeviceRemove(dev) => RawMessage { + topic: "bridge/request/device/remove".into(), + payload: serde_json::to_value(dev)?, + }, + _ => RawMessage { + topic: format!("{topic}/set"), + payload: serde_json::to_value(payload)?, + }, + }; + + let json = serde_json::to_string(&api_req)?; + if matches!(payload, Z2mRequest::EntertainmentFrame(_)) { + log::trace!("[{}] Entertainment: {json}", self.name); + } else { + log::debug!("[{}] Sending {json}", self.name); + } + + let msg = Message::text(json); + Ok(self.socket.send(msg).await?) + } + + pub async fn send_scene_store(&mut self, topic: &str, name: &str, id: u32) -> ApiResult<()> { + let z2mreq = Z2mRequest::SceneStore { name, id }; + + self.send(topic, &z2mreq).await + } + + pub async fn send_scene_recall(&mut self, topic: &str, index: u32) -> ApiResult<()> { + let z2mreq = Z2mRequest::SceneRecall(index); + + self.send(topic, &z2mreq).await + } + + pub async fn send_scene_remove(&mut self, topic: &str, index: u32) -> ApiResult<()> { + let z2mreq = Z2mRequest::SceneRemove(index); + + self.send(topic, &z2mreq).await + } + + pub async fn send_update(&mut self, topic: &str, payload: &DeviceUpdate) -> ApiResult<()> { + let z2mreq = Z2mRequest::Update(payload); + + self.send(topic, &z2mreq).await + } + + pub async fn send_group_member_add( + &mut self, + topic: &str, + friendly_name: &str, + ) -> ApiResult<()> { + let change = GroupMemberChange { + device: friendly_name.to_string(), + group: topic.to_string(), + endpoint: None, + skip_disable_reporting: None, + }; + let z2mreq = Z2mRequest::GroupMemberAdd(change); + + self.send(topic, &z2mreq).await + } + + pub async fn send_group_member_remove( + &mut self, + topic: &str, + friendly_name: &str, + ) -> ApiResult<()> { + let change = GroupMemberChange { + device: friendly_name.to_string(), + group: topic.to_string(), + endpoint: None, + skip_disable_reporting: None, + }; + let z2mreq = Z2mRequest::GroupMemberRemove(change); + + self.send(topic, &z2mreq).await + } + + pub async fn send_zigbee_message(&mut self, topic: &str, msg: &ZigbeeMessage) -> ApiResult<()> { + let z2mreq = Z2mRequest::Raw(hue_zclcommand(msg)); + self.send(topic, &z2mreq).await + } + + pub async fn send_entertainment_frame( + &mut self, + topic: &str, + msg: &ZigbeeMessage, + ) -> ApiResult<()> { + let z2mreq = Z2mRequest::EntertainmentFrame(hue_zclcommand(msg)); + self.send(topic, &z2mreq).await + } + + pub async fn send_hue_effects(&mut self, topic: &str, hz: HueZigbeeUpdate) -> ApiResult<()> { + let data = hz.to_vec()?; + log::debug!("Sending hue-specific frame: {}", hex::encode(&data)); + + let z2mreq = Z2mRequest::Command { + cluster: 0xFC03, + command: 0, + payload: Z2mPayload { data }, + }; + + self.send(topic, &z2mreq).await + } + + pub async fn send_permit_join(&mut self, time: u32, device: Option) -> ApiResult<()> { + let z2mreq = Z2mRequest::PermitJoin(PermitJoin { time, device }); + + self.send("", &z2mreq).await + } + + pub async fn send_device_remove(&mut self, id: String) -> ApiResult<()> { + let z2mreq = Z2mRequest::DeviceRemove(DeviceRemove { id }); + + self.send("", &z2mreq).await + } +} + +impl Stream for Z2mWebSocket +where + Self: Unpin, +{ + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + WebSocketStream::poll_next(Pin::new(&mut self.socket), cx) + } +} diff --git a/src/backend/z2m/zclcommand.rs b/src/backend/z2m/zclcommand.rs new file mode 100644 index 0000000..374efd7 --- /dev/null +++ b/src/backend/z2m/zclcommand.rs @@ -0,0 +1,31 @@ +use hue::zigbee::ZigbeeMessage; +use serde_json::{Value, json}; + +/// Use the low-level endpoint for `Zigbee2MQTT`, which allows free-form zigbee +/// messages to be sent. +/// +/// NOTE: The generated z2m payload only works on z2m instances with +/// Zigbee-Herdsman-Converter version 23.0.0 or greater. +/// +/// This is the case for z2m version 2.1.1 and newer. +/// +/// Older versions WILL NOT WORK. +#[must_use] +pub fn hue_zclcommand(msg: &ZigbeeMessage) -> Value { + json!({ + "zclcommand": { + "cluster": msg.cluster, + "command": msg.command, + "payload": { + "data": msg.data, + }, + "frametype": msg.frametype, + "options": { + "manufacturerCode": msg.mfc, + "disableDefaultResponse": msg.ddr, + "direction": 0, + "timeout": 100.0, + }, + } + }) +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..15a6f62 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,17 @@ +use camino::Utf8Path; +use config::{Config, ConfigError}; + +pub use bifrost_api::config::*; + +pub fn parse(filename: &Utf8Path) -> Result { + let settings = Config::builder() + .set_default("bifrost.state_file", "state.yaml")? + .set_default("bifrost.cert_file", "cert.pem")? + .set_default("bridge.http_port", 80)? + .set_default("bridge.https_port", 443)? + .set_default("bridge.entm_port", 2100)? + .add_source(config::File::with_name(filename.as_str())) + .build()?; + + settings.try_deserialize() +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..9f44f83 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,190 @@ +use std::num::{ParseIntError, TryFromIntError}; +use std::sync::Arc; + +use camino::Utf8PathBuf; +use hue::api::RType; +use thiserror::Error; +use tokio::task::JoinError; + +use bifrost_api::backend::BackendRequest; +use hue::event::EventBlock; +use svc::error::SvcError; + +#[derive(Error, Debug)] +pub enum ApiError { + /* mapped errors */ + #[error(transparent)] + FromUtf8Error(#[from] std::string::FromUtf8Error), + + #[error(transparent)] + SerdeJson(#[from] serde_json::Error), + + #[error(transparent)] + SerdeYaml(#[from] serde_yml::Error), + + #[error(transparent)] + IOError(#[from] std::io::Error), + + #[error(transparent)] + JoinError(#[from] JoinError), + + #[error(transparent)] + ParseIntError(#[from] ParseIntError), + + #[error(transparent)] + TryFromIntError(#[from] TryFromIntError), + + #[error(transparent)] + FromHexError(#[from] hex::FromHexError), + + #[error(transparent)] + UrlParseError(#[from] url::ParseError), + + #[error(transparent)] + MdnsSdError(#[from] mdns_sd::Error), + + #[error(transparent)] + ConfigError(#[from] config::ConfigError), + + #[error(transparent)] + QuickXmlSeError(#[from] quick_xml::se::SeError), + + #[error(transparent)] + NixError(#[from] nix::Error), + + #[error(transparent)] + SendErrorHue(#[from] tokio::sync::broadcast::error::SendError), + + #[error(transparent)] + SendErrorZ2m(#[from] tokio::sync::broadcast::error::SendError>), + + #[error(transparent)] + SetLoggerError(#[from] log::SetLoggerError), + + #[error(transparent)] + BroadcastStreamRecvError(#[from] tokio_stream::wrappers::errors::BroadcastStreamRecvError), + + #[error(transparent)] + TokioRecvError(#[from] tokio::sync::broadcast::error::RecvError), + + #[error(transparent)] + AxumError(#[from] axum::Error), + + #[error(transparent)] + TungsteniteError(#[from] tokio_tungstenite::tungstenite::Error), + + #[error(transparent)] + X509DerError(#[from] der::Error), + + #[error(transparent)] + X509SpkiError(#[from] x509_cert::spki::Error), + + #[error(transparent)] + X509BuilderError(#[from] x509_cert::builder::Error), + + #[error(transparent)] + X509DerConstOidError(#[from] der::oid::Error), + + #[error(transparent)] + P256Pkcs8Error(#[from] p256::pkcs8::Error), + + #[error(transparent)] + ReqwestError(#[from] reqwest::Error), + + #[error(transparent)] + UuidError(#[from] uuid::Error), + + #[error(transparent)] + HueError(#[from] hue::error::HueError), + + #[error(transparent)] + OpenSslError(#[from] openssl::error::Error), + + #[error(transparent)] + OpenSslErrors(#[from] openssl::error::ErrorStack), + + #[error(transparent)] + SslError(#[from] openssl::ssl::Error), + + #[error(transparent)] + NativeTlsError(#[from] native_tls::Error), + + #[error("Service error: {0}")] + SvcError(String), + + /* zigbee2mqtt errors */ + #[error("Unexpected eof on z2m socket")] + UnexpectedZ2mEof, + + #[error("Unexpected z2m message: {0:?}")] + UnexpectedZ2mReply(tokio_tungstenite::tungstenite::Message), + + /* hue api v2 errors */ + #[error("Failed to get firmware version reply from update server")] + NoUpdateInformation, + + /* bifrost errors: routes */ + #[error("Creating object of type {0:?} is not yet supported by Bifrost")] + CreateNotYetSupported(RType), + + #[error("Creating object of type {0:?} is not allowed by hue protocol")] + CreateNotAllowed(RType), + + #[error("Updating object of type {0:?} is not yet supported by Bifrost")] + UpdateNotYetSupported(RType), + + #[error("Updating object of type {0:?} is not allowed by hue protocol")] + UpdateNotAllowed(RType), + + #[error("Deleting object of type {0:?} is not yet supported by Bifrost")] + DeleteNotYetSupported(RType), + + #[error("Deleting object of type {0:?} is not allowed by hue protocol")] + DeleteNotAllowed(RType), + + /* bifrost errors */ + #[error("Missing auxiliary data resource {0:?}")] + AuxNotFound(uuid::Uuid), + + #[error("Cannot parse state file: no version field found")] + StateVersionNotFound, + + #[error("Cannot load certificate: {0:?}")] + Certificate(Utf8PathBuf, std::io::Error), + + #[error("Cannot load certificate: {0:?}")] + CertificateOpenSSL(Utf8PathBuf, openssl::ssl::Error), + + #[error("Cannot parse certificate: {0:?}")] + CertificateInvalid(Utf8PathBuf), + + #[error("Invalid hex color")] + InvalidHexColor, + + #[error("Entertainment Stream init error")] + EntStreamInitError, + + #[error("Entertainment Stream timeout")] + EntStreamTimeout, + + #[error("Entertainment Stream desynchronized")] + EntStreamDesync, + + #[error("Invalid zigbee message")] + ZigbeeMessageError, +} + +impl From for ApiError { + fn from(value: SvcError) -> Self { + Self::SvcError(value.to_string()) + } +} + +impl ApiError { + #[allow(clippy::needless_pass_by_value)] + pub fn service_error(value: impl ToString) -> Self { + Self::SvcError(value.to_string()) + } +} + +pub type ApiResult = Result; diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..11e3b2c --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,7 @@ +pub mod backend; +pub mod config; +pub mod error; +pub mod model; +pub mod resource; +pub mod routes; +pub mod server; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..dc751c7 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,188 @@ +use std::io::Write; + +use bifrost::backend; +use bifrost::config; +use bifrost::error::ApiResult; +use bifrost::server::appstate::AppState; +use bifrost::server::http::HttpServer; +use bifrost::server::mdns::MdnsService; +use bifrost::server::{self, Protocol}; +use svc::manager::ServiceManager; +use svc::manager::SvmClient; +use svc::serviceid::ServiceId; +use tokio::signal; +use tokio::signal::unix::SignalKind; + +/* + * Formatter function to output in syslog format. This makes sense when running + * as a service (where output might go to a log file, or the system journal) + */ +#[allow(clippy::match_same_arms)] +fn syslog_format( + buf: &mut pretty_env_logger::env_logger::fmt::Formatter, + record: &log::Record, +) -> std::io::Result<()> { + writeln!( + buf, + "<{}>{}: {}", + match record.level() { + log::Level::Error => 3, + log::Level::Warn => 4, + log::Level::Info => 6, + log::Level::Debug => 7, + log::Level::Trace => 7, + }, + record.target(), + record.args() + ) +} + +fn init_logging() -> ApiResult<()> { + /* Try to provide reasonable default filters, when RUST_LOG is not specified */ + const DEFAULT_LOG_FILTERS: &[&str] = &[ + "debug", + "mdns_sd=off", + "tokio_ssdp=info", + "tower_http::trace::on_request=info", + "h2=info", + "axum::rejection=trace", + ]; + + let log_filters = std::env::var("RUST_LOG").unwrap_or_else(|_| DEFAULT_LOG_FILTERS.join(",")); + + /* Detect if we need syslog or human-readable formatting */ + if std::env::var("SYSTEMD_EXEC_PID").is_ok_and(|pid| pid == std::process::id().to_string()) { + Ok(pretty_env_logger::env_logger::builder() + .format(syslog_format) + .parse_filters(&log_filters) + .try_init()?) + } else { + Ok(pretty_env_logger::formatted_timed_builder() + .parse_filters(&log_filters) + .try_init()?) + } +} + +#[allow(clippy::similar_names)] +async fn build_tasks(appstate: &AppState) -> ApiResult<()> { + let bconf = &appstate.config().bridge; + + let mut mgr = appstate.manager(); + + mgr.register_service("mdns", MdnsService::new(bconf.mac, bconf.ipaddress)) + .await?; + + log::info!("Serving mac [{}]", bconf.mac); + + // register plain http service + let http_service = HttpServer::http( + bconf.ipaddress, + bconf.http_port, + server::build_service(Protocol::Http, appstate.clone()), + ); + mgr.register_service("http", http_service).await?; + + let https_service = HttpServer::https_openssl( + bconf.ipaddress, + bconf.https_port, + server::build_service(Protocol::Https, appstate.clone()), + &appstate.config().bifrost.cert_file, + )?; + + // .. if either tls backend is enabled, register https service + mgr.register_service("https", https_service).await?; + + // register config writer + let svc = server::config_writer( + appstate.res.clone(), + appstate.config().bifrost.state_file.clone(), + ); + mgr.register_function("config-writer", svc).await?; + + // register version updater + let svc = server::version_updater(appstate.res.clone(), appstate.updater()); + mgr.register_function("version-updater", svc).await?; + + // register ssdp listener + let svc = server::ssdp::SsdpService::new(bconf.mac, bconf.ipaddress, appstate.updater()); + mgr.register_service("ssdp", svc).await?; + + // register entertainment streaming listener + let svc = server::entertainment::EntertainmentService::new( + bconf.ipaddress, + bconf.entm_port, + appstate.res.clone(), + )?; + mgr.register_service("entertainment", svc).await?; + + // register all z2m backends as services + let template = backend::z2m::Z2mServiceTemplate::new(appstate.clone()); + mgr.register_template("z2m", template).await?; + + // start named z2m instances, since templated services appear when started + for name in appstate.config().z2m.servers.keys() { + mgr.start(ServiceId::instance("z2m", name)).await?; + } + + // finally, iterate over all services and start them + for (id, _name) in mgr.list().await? { + mgr.start(id).await?; + } + + Ok(()) +} + +fn install_signal_handlers(appstate: &AppState) -> ApiResult<()> { + async fn shutdown(msg: &str, mut mgr: SvmClient) { + log::warn!("{msg}"); + let _ = std::io::stderr().flush(); + let _ = mgr.shutdown().await; + } + + let mgr = appstate.manager(); + tokio::spawn(async move { + if matches!(signal::ctrl_c().await, Ok(())) { + shutdown("Ctrl-C pressed, exiting..", mgr).await; + } + }); + + let mgr = appstate.manager(); + let mut signal = signal::unix::signal(SignalKind::terminate())?; + tokio::spawn(async move { + if matches!(signal.recv().await, Some(())) { + shutdown("SIGTERM received, exiting..", mgr).await; + } + }); + + Ok(()) +} + +async fn run() -> ApiResult<()> { + init_logging()?; + + #[cfg(feature = "server-banner")] + server::banner::print()?; + + let config = config::parse("config.yaml".into())?; + log::debug!("Configuration loaded successfully"); + + let (client, future) = ServiceManager::spawn(); + + let appstate = AppState::from_config(config, client).await?; + + install_signal_handlers(&appstate)?; + + build_tasks(&appstate).await?; + + future.await??; + + Ok(()) +} + +#[tokio::main] +async fn main() { + if let Err(err) = run().await { + log::error!("Bifrost error: {err}"); + log::error!("Fatal error encountered, cannot continue."); + } +} diff --git a/src/model/mod.rs b/src/model/mod.rs new file mode 100644 index 0000000..67c33fb --- /dev/null +++ b/src/model/mod.rs @@ -0,0 +1,3 @@ +pub mod state; +pub mod throttle; +pub mod upnp; diff --git a/src/model/state.rs b/src/model/state.rs new file mode 100644 index 0000000..feaf092 --- /dev/null +++ b/src/model/state.rs @@ -0,0 +1,249 @@ +use std::collections::BTreeMap; +use std::io::Read; + +use serde::{Deserialize, Serialize}; +use serde_yml::Value; +use uuid::Uuid; + +use hue::api::{DeviceArchetype, Resource}; +use hue::error::{HueError, HueResult}; +use hue::version::SwVersion; + +use crate::error::{ApiError, ApiResult}; + +#[derive(Serialize, Deserialize, Clone, Debug, Default)] +pub struct AuxData { + pub topic: Option, + pub index: Option, +} + +impl AuxData { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub fn with_topic(self, topic: &str) -> Self { + Self { + topic: Some(topic.to_string()), + ..self + } + } + + #[must_use] + pub fn with_index(self, index: u32) -> Self { + Self { + index: Some(index), + ..self + } + } +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct IdMap { + forward: BTreeMap, + reverse: BTreeMap, + #[serde(skip)] + next_id: u32, +} + +impl IdMap { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + fn find_next_id(&mut self) -> u32 { + while self.reverse.contains_key(&self.next_id) { + self.next_id += 1; + } + self.next_id + } + + pub fn add(&mut self, uuid: Uuid) -> u32 { + if let Some(id) = self.forward.get(&uuid).copied() { + return id; + } + + let id = self.find_next_id(); + + self.forward.insert(uuid, id); + self.reverse.insert(id, uuid); + + id + } + + #[must_use] + pub fn id(&self, uuid: &Uuid) -> Option { + self.forward.get(uuid).copied() + } + + #[must_use] + pub fn uuid(&self, id: &u32) -> Option { + self.reverse.get(id).copied() + } + + pub fn remove(&mut self, uuid: &Uuid) { + if let Some(id) = self.forward.remove(uuid) { + self.reverse.remove(&id); + } + } +} + +#[derive(Clone, Default, Debug, Serialize, Deserialize)] +pub enum StateVersion { + /// Version 0: (`res`, `aux`) tuple, no version field in state + V0 = 0, + + #[default] + /// Version 1: { `version`, `aux`, `id_v1`, `res` } map + V1 = 1, +} + +#[derive(Clone, Default, Debug, Serialize, Deserialize)] +pub struct State { + version: StateVersion, + aux: BTreeMap, + id_v1: IdMap, + pub res: BTreeMap, +} + +impl State { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + pub fn version(state: &Value) -> ApiResult { + if state.is_sequence() { + return Ok(StateVersion::V0); + } + + if let Some(version) = state.get("version") { + return Ok(StateVersion::deserialize(version)?); + } + + Err(ApiError::StateVersionNotFound) + } + + pub fn patch_bridge_version(&mut self, version: &SwVersion) { + let software_version = version.get_software_version(); + for (uuid, value) in &mut self.res { + let Resource::Device(dev) = value else { + continue; + }; + + let pd = &mut dev.product_data; + + if pd.model_id != hue::HUE_BRIDGE_V2_MODEL_ID { + continue; + } + + if pd.product_archetype != DeviceArchetype::BridgeV2 { + continue; + } + + if pd.software_version >= software_version { + log::info!("Bridge device {uuid} already on newest firmware version"); + } else { + log::info!( + "Bridge device {uuid} on older firmware {}. Updating to {}", + pd.software_version, + &software_version + ); + pd.software_version.clone_from(&software_version); + } + } + } + + pub fn from_v0(state: Value) -> ApiResult { + let (v0res, v0aux): (serde_yml::Mapping, serde_yml::Mapping) = + serde_yml::from_value(state)?; + + let mut aux = BTreeMap::new(); + let mut res = BTreeMap::new(); + + log::debug!("Importing aux data from old v0 state.."); + for (key, value) in v0aux { + log::debug!(" {key:?}: {value:?}"); + aux.insert(serde_yml::from_value(key)?, serde_yml::from_value(value)?); + } + + log::debug!("Importing res data from old v0 state.."); + for (key, value) in v0res { + log::debug!(" {key:?}: {value:?}"); + res.insert(serde_yml::from_value(key)?, serde_yml::from_value(value)?); + } + + /* generate all missing id_v1 entries */ + log::debug!("Synthesizing id_v1 entries for all resources.."); + let mut id_v1 = IdMap::new(); + for key in res.keys() { + id_v1.add(*key); + } + + /* construct upgraded state */ + Ok(Self { + version: StateVersion::V1, + aux, + id_v1, + res, + }) + } + + pub fn from_v1(state: Value) -> ApiResult { + Ok(serde_yml::from_value(state)?) + } + + pub fn from_reader(rdr: impl Read) -> ApiResult { + let state = serde_yml::from_reader(rdr)?; + match Self::version(&state)? { + StateVersion::V0 => Self::from_v0(state), + StateVersion::V1 => Self::from_v1(state), + } + } + + pub fn aux_get(&self, id: &Uuid) -> ApiResult<&AuxData> { + self.aux.get(id).ok_or(ApiError::AuxNotFound(*id)) + } + + pub fn aux_set(&mut self, id: Uuid, aux: AuxData) { + self.aux.insert(id, aux); + } + + #[must_use] + pub fn try_get(&self, id: &Uuid) -> Option<&Resource> { + self.res.get(id) + } + + pub fn get(&self, id: &Uuid) -> HueResult<&Resource> { + self.try_get(id).ok_or(HueError::NotFound(*id)) + } + + pub fn get_mut(&mut self, id: &Uuid) -> HueResult<&mut Resource> { + self.res.get_mut(id).ok_or(HueError::NotFound(*id)) + } + + pub fn insert(&mut self, key: Uuid, value: Resource) { + self.res.insert(key, value); + self.id_v1.add(key); + } + + pub fn remove(&mut self, id: &Uuid) -> ApiResult<()> { + self.aux.remove(id); + self.id_v1.remove(id); + self.res.remove(id).ok_or(HueError::NotFound(*id))?; + Ok(()) + } + + #[must_use] + pub fn id_v1(&self, uuid: &Uuid) -> Option { + self.id_v1.id(uuid) + } + + #[must_use] + pub fn from_id_v1(&self, id: &u32) -> Option { + self.id_v1.uuid(id) + } +} diff --git a/src/model/throttle.rs b/src/model/throttle.rs new file mode 100644 index 0000000..eff4ba1 --- /dev/null +++ b/src/model/throttle.rs @@ -0,0 +1,88 @@ +use std::collections::VecDeque; + +use chrono::{DateTime, Duration, Utc}; + +pub struct Throttle { + interval: Duration, + last_update: DateTime, +} + +impl Throttle { + #[must_use] + pub fn new(interval: Duration) -> Self { + Self { + interval, + last_update: Utc::now(), + } + } + + #[must_use] + pub fn from_fps(fps: u32) -> Self { + let interval = Duration::microseconds(1_000_000 / i64::from(fps)); + Self::new(interval) + } + + #[must_use] + pub fn elapsed(&self) -> Duration { + self.elapsed_since(Utc::now()) + } + + #[must_use] + pub const fn interval(&self) -> Duration { + self.interval + } + + #[must_use] + pub fn elapsed_since(&self, now: DateTime) -> Duration { + now - self.last_update + } + + pub fn tick(&mut self) -> bool { + let now = Utc::now(); + let ready = self.elapsed_since(now) >= self.interval; + if ready { + self.last_update = now; + } + + ready + } +} + +pub struct ThrottleQueue { + throttle: Throttle, + queue: VecDeque, + capacity: usize, +} + +impl ThrottleQueue { + #[must_use] + pub const fn new(throttle: Throttle, capacity: usize) -> Self { + Self { + throttle, + queue: VecDeque::new(), + capacity, + } + } + + pub fn push(&mut self, value: T) -> bool { + if !self.throttle.tick() { + return false; + } + + if self.queue.len() >= self.capacity { + return false; + } + + self.queue.push_front(value); + + true + } + + pub fn pop(&mut self) -> Option { + self.queue.pop_back() + } + + pub fn clear(&mut self) { + self.queue.clear(); + } +} diff --git a/src/model/upnp.rs b/src/model/upnp.rs new file mode 100644 index 0000000..115ef4f --- /dev/null +++ b/src/model/upnp.rs @@ -0,0 +1,459 @@ +use serde::{Deserialize, Serialize}; +use url::Url; +use uuid::Uuid; + +const XML_DOCTYPE: &str = r#""#; + +const XMLNS: &str = "urn:schemas-upnp-org:device-1-0"; +const SCHEMA_DEVICE_BASIC: &str = "urn:schemas-upnp-org:device:Basic:1"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename = "root")] +pub struct Root { + #[serde(rename = "@xmlns")] + xmlns: String, + + #[serde(rename = "specVersion")] + pub spec_version: SpecVersion, + + #[serde(rename = "URLBase")] + pub url_base: Url, + + pub device: Device, +} + +impl Root { + #[must_use] + pub fn new(url_base: Url, device: Device) -> Self { + Self { + xmlns: XMLNS.to_string(), + spec_version: SpecVersion::VERSION_1, + url_base, + device, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpecVersion { + pub major: u32, + pub minor: u32, +} + +impl SpecVersion { + pub const VERSION_1: Self = Self { major: 1, minor: 0 }; +} + +impl Default for SpecVersion { + fn default() -> Self { + Self::VERSION_1 + } +} + +mod prefixed_uuid { + use serde::{Deserialize, Deserializer, Serializer}; + use uuid::Uuid; + const PREFIX: &str = "uuid:"; + + pub fn serialize(value: &Uuid, serializer: S) -> Result + where + S: Serializer, + { + let s = format!("{PREFIX}{value}"); + serializer.serialize_str(&s) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + use serde::de::Error; + let s: &str = Deserialize::deserialize(deserializer)?; + let uuid = s + .strip_prefix(PREFIX) + .ok_or_else(|| D::Error::custom("Value does not start with 'uuid:' prefix"))?; + + Uuid::parse_str(uuid).map_err(D::Error::custom) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Device { + pub device_type: String, + + pub friendly_name: String, + + pub manufacturer: String, + + #[serde(rename = "manufacturerURL", skip_serializing_if = "Option::is_none")] + pub manufacturer_url: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub model_description: Option, + + pub model_name: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub model_number: Option, + + #[serde(rename = "modelURL")] + pub model_url: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub serial_number: Option, + + #[serde(rename = "UDN", with = "prefixed_uuid")] + pub udn: Uuid, + + #[serde(rename = "UPC", skip_serializing_if = "Option::is_none")] + pub upc: Option, + + #[serde(skip_serializing_if = "Vec::is_empty")] + pub icon_list: Vec, + + #[serde(skip_serializing_if = "Vec::is_empty")] + pub service_list: Vec, + + #[serde(skip_serializing_if = "Vec::is_empty")] + pub device_list: Vec, + + #[serde(rename = "presentationURL", skip_serializing_if = "Option::is_none")] + pub presentation_url: Option, +} + +impl Device { + pub fn new( + friendly_name: impl AsRef, + manufacturer: impl AsRef, + model_name: impl AsRef, + udn: Uuid, + ) -> Self { + Self { + device_type: SCHEMA_DEVICE_BASIC.to_string(), + friendly_name: friendly_name.as_ref().into(), + manufacturer: manufacturer.as_ref().into(), + model_name: model_name.as_ref().into(), + manufacturer_url: None, + model_description: None, + model_number: None, + model_url: None, + serial_number: None, + udn, + upc: None, + icon_list: vec![], + service_list: vec![], + device_list: vec![], + presentation_url: None, + } + } + + #[must_use] + pub fn with_manufacturer_url(self, value: Url) -> Self { + Self { + manufacturer_url: Some(value), + ..self + } + } + + #[must_use] + pub fn with_model_description(self, value: impl Into) -> Self { + Self { + model_description: Some(value.into()), + ..self + } + } + + #[must_use] + pub fn with_model_number(self, value: impl Into) -> Self { + Self { + model_number: Some(value.into()), + ..self + } + } + + #[must_use] + pub fn with_model_url(self, value: Url) -> Self { + Self { + model_url: Some(value), + ..self + } + } + + #[must_use] + pub fn with_serial_number(self, value: impl Into) -> Self { + Self { + serial_number: Some(value.into()), + ..self + } + } + + #[must_use] + pub fn with_upc(self, value: String) -> Self { + Self { + upc: Some(value), + ..self + } + } + + #[must_use] + pub fn with_presentation_url(self, value: impl Into) -> Self { + Self { + presentation_url: Some(value.into()), + ..self + } + } + + #[must_use] + pub fn with_device(mut self, value: Self) -> Self { + self.add_device(value); + self + } + + pub fn add_device(&mut self, value: Self) { + self.device_list.push(value); + } +} + +pub fn to_xml(value: impl Serialize) -> Result { + let mut res = XML_DOCTYPE.to_string() + "\n"; + + // set up a serializer with indentation that appends to `res` + let mut ser = quick_xml::se::Serializer::new(&mut res); + ser.indent(' ', 2); + + // serialize value, with final newline + value.serialize(ser)?; + res.push('\n'); + + Ok(res) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Icon { + mimetype: String, + width: u32, + height: u32, + depth: u32, + url: Url, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Service { + #[serde(rename = "serviceType")] + service_type: Url, + + #[serde(rename = "serviceId")] + service_id: Url, + + #[serde(rename = "SCPDURL")] + scpd_url: Url, + + #[serde(rename = "controlURL")] + control_url: Url, + + #[serde(rename = "eventSubURL")] + event_sub_url: Url, +} + +#[cfg(test)] +mod tests { + use serde::{Deserialize, Serialize}; + use url::Url; + use uuid::{Uuid, uuid}; + + const UUID: Uuid = uuid!("01234567-89ab-cdef-0123-456789abcdef"); + + use crate::model::upnp::{ + Device, Icon, Root, SCHEMA_DEVICE_BASIC, Service, XML_DOCTYPE, XMLNS, to_xml, + }; + + // convert using `to_xml()`, but trim lines to avoid having to indent test results + fn make_xml(obj: impl Serialize) -> String { + to_xml(&obj).unwrap().lines().map(str::trim).collect() + } + + #[test] + fn uuid_prefix() { + #[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] + struct Prefixed { + #[serde(with = "super::prefixed_uuid")] + uuid: Uuid, + } + + let orig = Prefixed { uuid: UUID }; + let json = serde_json::to_string(&orig).unwrap(); + + let expected = format!(r#"{{"uuid":"uuid:{UUID}"}}"#); + + assert_eq!(json, expected); + + let parsed: Prefixed = serde_json::from_str(&json).unwrap(); + + assert_eq!(orig, parsed); + } + + #[test] + fn serialize_service() { + let svc = Service { + service_type: Url::parse("http://service_type/").unwrap(), + service_id: Url::parse("http://service_id/").unwrap(), + scpd_url: Url::parse("http://scpd_url/").unwrap(), + control_url: Url::parse("http://control_url/").unwrap(), + event_sub_url: Url::parse("http://event_sub_url/").unwrap(), + }; + + let a = make_xml(&svc); + let b = [ + XML_DOCTYPE, + "", + "http://service_type/", + "http://service_id/", + "http://scpd_url/", + "http://control_url/", + "http://event_sub_url/", + "", + ] + .concat(); + + assert_eq!(a, b); + } + + #[test] + fn serialize_icon() { + let icon = Icon { + mimetype: "mime/type".into(), + width: 42, + height: 32, + depth: 17, + url: Url::parse("http://example.org/icon.png").unwrap(), + }; + + let a = make_xml(&icon); + let b = [ + XML_DOCTYPE, + "", + "mime/type", + "42", + "32", + "17", + "http://example.org/icon.png", + "", + ] + .concat(); + + assert_eq!(a, b); + } + + #[test] + fn serialize_device() { + let friendly_name = "Plumbus"; + let manufacturer = "Plumbubo Prime 51b"; + let model_name = "Plumbus 9000"; + let udn = UUID; + let dev = Device::new(friendly_name, manufacturer, model_name, udn); + + let a = make_xml(&dev); + let b = [ + XML_DOCTYPE, + "", + "urn:schemas-upnp-org:device:Basic:1", + "Plumbus", + "Plumbubo Prime 51b", + "Plumbus 9000", + "", + "uuid:01234567-89ab-cdef-0123-456789abcdef", + "", + ] + .concat(); + + assert_eq!(a, b); + } + + #[test] + fn serialize_device_with_subdevice() { + let friendly_name = "Plumbus"; + let manufacturer = "Plumbubo Prime 51b"; + let model_name = "Plumbus 9000"; + let udn = UUID; + let mut dev = Device::new(friendly_name, manufacturer, model_name, udn); + + dev.device_list.push(dev.clone()); + + let device_body = [ + "urn:schemas-upnp-org:device:Basic:1", + "Plumbus", + "Plumbubo Prime 51b", + "Plumbus 9000", + "", + "uuid:01234567-89ab-cdef-0123-456789abcdef", + ] + .concat(); + + let a = make_xml(&dev); + let b = [ + XML_DOCTYPE, + "", + &device_body, + "", + &device_body, + "", + "", + ] + .concat(); + + assert_eq!(a, b); + } + + #[test] + fn serialize_root() { + let friendly_name = "Plumbus"; + let manufacturer = "Plumbubo Prime 51b"; + let model_name = "Plumbus 9000"; + let presentation_url = "index.html"; + let model_description = "Special Fleep Edition"; + let model_url = "portal://51b.prime.plumbubo/plumbus9000"; + let manufacturer_url = "portal:://51b.prime.plumbubo"; + let serial_number = "C137"; + let model_number = "PB9000"; + let base_url = "http://example.org/base"; + let udn = UUID; + let base_url = Url::parse(base_url).unwrap(); + let dev = Device::new(friendly_name, manufacturer, model_name, udn) + .with_manufacturer_url(Url::parse(manufacturer_url).unwrap()) + .with_presentation_url(presentation_url) + .with_model_description(model_description) + .with_model_url(Url::parse(model_url).unwrap()) + .with_model_number(model_number) + .with_serial_number(serial_number); + let root = Root::new(base_url.clone(), dev); + + let a = make_xml(&root); + let b = [ + XML_DOCTYPE, + &format!(""), + "", + "1", + "0", + "", + &format!("{base_url}"), + "", + &format!("{SCHEMA_DEVICE_BASIC}"), + &format!("{friendly_name}"), + &format!("{manufacturer}"), + &format!("{manufacturer_url}"), + &format!("{model_description}"), + &format!("{model_name}"), + &format!("{model_number}"), + &format!("{model_url}"), + &format!("{serial_number}"), + &format!("uuid:{UUID}"), + &format!("{presentation_url}"), + "", + "", + ] + .concat(); + + assert_eq!(a, b); + } +} diff --git a/src/resource.rs b/src/resource.rs new file mode 100644 index 0000000..88d6a43 --- /dev/null +++ b/src/resource.rs @@ -0,0 +1,601 @@ +use std::collections::HashSet; +use std::io::{Read, Write}; +use std::sync::Arc; + +use itertools::Itertools; +use maplit::btreeset; +use serde::Serialize; +use serde_json::json; +use tokio::sync::Notify; +use tokio::sync::broadcast::{Receiver, Sender}; +use uuid::Uuid; + +use bifrost_api::backend::BackendRequest; +use hue::api::{ + Bridge, BridgeHome, Device, DeviceArchetype, DeviceProductData, DimmingUpdate, Entertainment, + EntertainmentConfiguration, GroupedLight, Light, Metadata, On, RType, Resource, ResourceLink, + ResourceRecord, Room, Stub, TimeZone, ZigbeeConnectivity, ZigbeeConnectivityStatus, + ZigbeeDeviceDiscovery, ZigbeeDeviceDiscoveryAction, ZigbeeDeviceDiscoveryStatus, Zone, +}; +use hue::error::{HueError, HueResult}; +use hue::event::EventBlock; +use hue::version::SwVersion; + +use crate::error::ApiResult; +use crate::model::state::{AuxData, State}; +use crate::server::hueevents::HueEventStream; + +#[derive(Clone, Debug)] +pub struct Resources { + state: State, + version: SwVersion, + state_updates: Arc, + backend_updates: Sender>, + hue_event_stream: HueEventStream, +} + +impl Resources { + const MAX_SCENE_ID: u32 = 100; + const HUE_EVENTS_BUFFER_SIZE: usize = 128; + + #[allow(clippy::new_without_default)] + #[must_use] + pub fn new(version: SwVersion, state: State) -> Self { + Self { + state, + version, + state_updates: Arc::new(Notify::new()), + backend_updates: Sender::new(32), + hue_event_stream: HueEventStream::new(Self::HUE_EVENTS_BUFFER_SIZE), + } + } + + pub fn update_bridge_version(&mut self, version: SwVersion) { + self.version = version; + self.state.patch_bridge_version(&self.version); + self.state_updates.notify_one(); + } + + pub fn reset_all_streaming(&mut self) -> ApiResult<()> { + for id in self.get_resource_ids_by_type(RType::Light) { + let light: &Light = self.get_id(id)?; + if light.is_streaming() { + log::warn!("Clearing streaming state of Light {}", id); + self.update(&id, Light::stop_streaming)?; + } + } + + for id in self.get_resource_ids_by_type(RType::EntertainmentConfiguration) { + let ec: &EntertainmentConfiguration = self.get_id(id)?; + if ec.is_streaming() { + log::warn!("Clearing streaming state of EntertainmentConfiguration {id}"); + self.update(&id, EntertainmentConfiguration::stop_streaming)?; + } + } + + Ok(()) + } + + pub fn read(&mut self, rdr: impl Read) -> ApiResult<()> { + self.state = State::from_reader(rdr)?; + Ok(()) + } + + pub fn write(&self, wr: impl Write) -> ApiResult<()> { + Ok(serde_yml::to_writer(wr, &self.state)?) + } + + pub fn serialize(&self) -> ApiResult { + Ok(serde_yml::to_string(&self.state)?) + } + + pub fn init(&mut self, bridge_id: &str) -> ApiResult<()> { + self.add_bridge(bridge_id.to_owned()) + } + + pub fn aux_get(&self, link: &ResourceLink) -> ApiResult<&AuxData> { + self.state.aux_get(&link.rid) + } + + pub fn aux_set(&mut self, link: &ResourceLink, aux: AuxData) { + self.state.aux_set(link.rid, aux); + } + + pub fn try_update( + &mut self, + id: &Uuid, + func: impl FnOnce(&mut T) -> ApiResult<()>, + ) -> ApiResult<()> + where + for<'a> &'a mut T: TryFrom<&'a mut Resource, Error = HueError>, + { + let id_v1 = self.id_v1_scope(id, self.state.get(id)?); + let resource = self.state.get_mut(id)?; + + let obj: &mut T = resource.try_into()?; + + // capture before and after serializations of object + let before = serde_json::to_value(&obj)?; + func(obj)?; + let after = serde_json::to_value(&obj)?; + + // if the function affected a meaningful difference, send an update event + if let Some(delta) = hue::diff::event_update_diff(before, after)? { + log::trace!("Hue event: {id_v1:?} {delta:#?}"); + self.hue_event_stream.hue_event(EventBlock::update( + id, + id_v1, + resource.rtype(), + delta, + )?); + + self.state_updates.notify_one(); + } + + Ok(()) + } + + pub fn update(&mut self, id: &Uuid, func: impl FnOnce(&mut T)) -> ApiResult<()> + where + for<'a> &'a mut T: TryFrom<&'a mut Resource, Error = HueError>, + { + self.try_update(id, |obj: &mut T| { + func(obj); + Ok(()) + }) + } + + pub fn update_by_type(&mut self, func: impl Fn(&mut T)) -> ApiResult<()> + where + for<'a> &'a mut T: TryFrom<&'a mut Resource, Error = HueError>, + { + let ids = self.state.res.keys().copied().collect_vec(); + for id in &ids { + let obj = self.state.get_mut(id)?; + let x: Result<&mut T, _> = obj.try_into(); + if x.is_ok() { + self.try_update(id, |obj: &mut T| { + func(obj); + Ok(()) + })?; + } + } + Ok(()) + } + + #[must_use] + pub fn get_scenes_for_room(&self, id: &Uuid) -> Vec { + self.state + .res + .iter() + .filter_map(|(k, v)| { + if let Resource::Scene(scn) = v { + if &scn.group.rid == id { Some(k) } else { None } + } else { + None + } + }) + .copied() + .collect() + } + + pub fn add(&mut self, link: &ResourceLink, obj: Resource) -> ApiResult<()> { + assert!( + link.rtype == obj.rtype(), + "Link type failed: {:?} expected but {:?} given", + link.rtype, + obj.rtype() + ); + + if self.state.res.contains_key(&link.rid) { + log::trace!("Resource {link:?} is already known"); + return Ok(()); + } + + self.state.insert(link.rid, obj); + + self.state_updates.notify_one(); + + let evt = EventBlock::add(vec![self.get_resource_by_id(&link.rid)?]); + + log::trace!("Send event: {evt:?}"); + + self.hue_event_stream.hue_event(evt); + + Ok(()) + } + + pub fn delete(&mut self, link: &ResourceLink) -> ApiResult<()> { + log::info!("Deleting {link:?}.."); + + // Delete references to this object from other objects + self.update_by_type(|bridge_home: &mut BridgeHome| { + bridge_home.children.remove(link); + bridge_home.services.remove(link); + })?; + + self.update_by_type(|device: &mut Device| { + device.services.remove(link); + })?; + + self.update_by_type(|ec: &mut EntertainmentConfiguration| { + ec.locations + .service_locations + .retain(|sl| sl.service != *link); + ec.channels + .retain(|chan| !chan.members.iter().any(|c| c.service == *link)); + ec.light_services.retain(|ls| ls != link); + })?; + + self.update_by_type(|room: &mut Room| { + room.children.remove(link); + room.services.remove(link); + })?; + + self.update_by_type(|zone: &mut Zone| { + zone.children.remove(link); + zone.services.remove(link); + })?; + + // Get id_v1 before deleting + let id_v1 = self.id_v1_scope(&link.rid, self.state.get(&link.rid)?); + + // Remove resource from state database + self.state.remove(&link.rid)?; + + // Find ids of all resources owned by the deleted node + let owned_by = self + .state + .res + .iter() + .filter_map(|(rid, res)| { + if res.owner() == Some(*link) { + Some(ResourceLink::new(*rid, res.rtype())) + } else { + None + } + }) + .collect_vec(); + + // Delete all resources owned by the deleted node + for owned in owned_by { + self.delete(&owned)?; + } + + self.state_updates.notify_one(); + + let evt = EventBlock::delete(*link, id_v1)?; + + self.hue_event_stream.hue_event(evt); + + Ok(()) + } + + pub fn add_bridge(&mut self, bridge_id: String) -> ApiResult<()> { + let link_bridge = RType::Bridge.deterministic(&bridge_id); + let link_bridge_home = RType::BridgeHome.deterministic(format!("{bridge_id}HOME")); + let link_bridge_dev = RType::Device.deterministic(link_bridge.rid); + let link_bridge_home_dev = RType::Device.deterministic(link_bridge_home.rid); + let link_bridge_ent = RType::Entertainment.deterministic(link_bridge.rid); + let link_zbdd = RType::ZigbeeDeviceDiscovery.deterministic(link_bridge.rid); + let link_zbc = RType::ZigbeeConnectivity.deterministic(link_bridge.rid); + let link_bhome_glight = RType::GroupedLight.deterministic(link_bridge_home.rid); + + let bridge_dev = Device { + product_data: DeviceProductData::hue_bridge_v2(&self.version), + metadata: Metadata::new(DeviceArchetype::BridgeV2, "Bifrost"), + services: btreeset![link_bridge, link_zbc, link_bridge_ent, link_zbdd], + identify: Some(Stub), + usertest: None, + }; + + let bridge = Bridge { + bridge_id, + owner: link_bridge_dev, + time_zone: TimeZone::best_guess(), + }; + + let bridge_home_dev = Device { + product_data: DeviceProductData::hue_bridge_v2(&self.version), + metadata: Metadata::new(DeviceArchetype::BridgeV2, "Bifrost Bridge Home"), + services: btreeset![link_bridge], + identify: None, + usertest: None, + }; + + let bridge_home = BridgeHome { + children: btreeset![link_bridge_dev], + services: btreeset![link_bhome_glight], + }; + + let bhome_glight = GroupedLight { + alert: json!({ + "action_values": [ + "breathe", + ] + }), + dimming: Some(DimmingUpdate { brightness: 8.7 }), + color: Some(Stub), + color_temperature: Some(Stub), + color_temperature_delta: Some(Stub), + dimming_delta: Stub, + dynamics: Stub, + on: Some(On { on: true }), + owner: link_bridge_home, + signaling: json!({ + "signal_values": [ + "alternating", + "no_signal", + "on_off", + "on_off_color", + ] + }), + }; + + let zbdd = ZigbeeDeviceDiscovery { + owner: link_bridge_dev, + status: ZigbeeDeviceDiscoveryStatus::Ready, + action: ZigbeeDeviceDiscoveryAction { + action_type_values: vec![], + search_codes: vec![], + }, + }; + + let zbc = ZigbeeConnectivity { + owner: link_bridge_dev, + mac_address: String::from("11:22:33:44:55:66:77:88"), + status: ZigbeeConnectivityStatus::Connected, + channel: Some(json!({ + "status": "set", + "value": "channel_25", + })), + extended_pan_id: None, + }; + + let brent = Entertainment { + equalizer: false, + owner: link_bridge_dev, + proxy: true, + renderer: false, + max_streams: Some(1), + renderer_reference: None, + segments: None, + }; + + self.add(&link_bridge_dev, Resource::Device(bridge_dev))?; + self.add(&link_bridge, Resource::Bridge(bridge))?; + self.add(&link_bridge_home_dev, Resource::Device(bridge_home_dev))?; + self.add(&link_bridge_home, Resource::BridgeHome(bridge_home))?; + self.add(&link_zbdd, Resource::ZigbeeDeviceDiscovery(zbdd))?; + self.add(&link_zbc, Resource::ZigbeeConnectivity(zbc))?; + self.add(&link_bridge_ent, Resource::Entertainment(brent))?; + self.add(&link_bhome_glight, Resource::GroupedLight(bhome_glight))?; + + Ok(()) + } + + pub fn get_next_scene_id(&self, room: &ResourceLink) -> HueResult { + let mut set: HashSet = HashSet::new(); + + for scene in self.get_resources_by_type(RType::Scene) { + let Resource::Scene(scn) = scene.obj else { + continue; + }; + + if &scn.group == room { + let Ok(AuxData { + index: Some(index), .. + }) = self.state.aux_get(&scene.id) + else { + continue; + }; + + set.insert(*index); + } + } + + for x in 0..Self::MAX_SCENE_ID { + if !set.contains(&x) { + return Ok(x); + } + } + Err(HueError::Full(RType::Scene)) + } + + pub fn get<'a, T>(&'a self, link: &ResourceLink) -> HueResult<&'a T> + where + &'a T: TryFrom<&'a Resource, Error = HueError>, + { + self.get_id(link.rid) + } + + pub fn get_id<'a, T>(&'a self, id: Uuid) -> HueResult<&'a T> + where + &'a T: TryFrom<&'a Resource, Error = HueError>, + { + self.state.get(&id)?.try_into() + } + + /* + behavior_script null + bridge_home /groups/{id} + bridge null + device /lights/{id} | null + entertainment /lights/{id} | null + geofence_client null + geolocation null + grouped_light /groups/{id} + homekit null + light /lights/{id} + matter null + room /groups/{id} + scene /scenes/{id} + smart_scene null + zigbee_connectivity /lights/{id} + zigbee_connectivity null + zigbee_device_discovery null + */ + + #[must_use] + fn id_v1_scope(&self, id: &Uuid, res: &Resource) -> Option { + let id = self.state.id_v1(id)?; + match res { + Resource::Light(_) => Some(format!("/lights/{id}")), + Resource::Scene(_) => Some(format!("/scenes/{id}")), + + /* GroupedLights are mapped to their (room) owner's id_v1 */ + Resource::GroupedLight(grp) => { + let id = self.state.id_v1(&grp.owner.rid)?; + Some(format!("/groups/{id}")) + } + + /* Rooms are mapped directly */ + Resource::Room(_) => Some(format!("/groups/{id}")), + + /* Devices (that are lights) map to the light service's id_v1 */ + Resource::Device(dev) => dev + .light_service() + .and_then(|light| self.state.id_v1(&light.rid)) + .map(|id| format!("/lights/{id}")), + + Resource::EntertainmentConfiguration(_dev) => Some(format!("/groups/{id}")), + + Resource::Entertainment(ent) => { + let dev: &Device = self.get(&ent.owner).ok()?; + dev.light_service() + .and_then(|light| self.state.id_v1(&light.rid)) + .map(|id| format!("/lights/{id}")) + } + + /* BridgeHome maps to "group 0" that seems to be present in the v1 api */ + Resource::BridgeHome(_) => Some(String::from("/groups/0")), + + /* No id v1 */ + Resource::AuthV1(_) + | Resource::BehaviorInstance(_) + | Resource::BehaviorScript(_) + | Resource::Bridge(_) + | Resource::Button(_) + | Resource::CameraMotion(_) + | Resource::Contact(_) + | Resource::DevicePower(_) + | Resource::DeviceSoftwareUpdate(_) + | Resource::GeofenceClient(_) + | Resource::Geolocation(_) + | Resource::GroupedLightLevel(_) + | Resource::GroupedMotion(_) + | Resource::Homekit(_) + | Resource::LightLevel(_) + | Resource::Matter(_) + | Resource::MatterFabric(_) + | Resource::Motion(_) + | Resource::PrivateGroup(_) + | Resource::PublicImage(_) + | Resource::RelativeRotary(_) + | Resource::ServiceGroup(_) + | Resource::SmartScene(_) + | Resource::Tamper(_) + | Resource::Taurus(_) + | Resource::Temperature(_) + | Resource::ZgpConnectivity(_) + | Resource::ZigbeeConnectivity(_) + | Resource::ZigbeeDeviceDiscovery(_) + | Resource::Zone(_) => None, + } + } + + fn make_resource_record(&self, id: &Uuid, res: &Resource) -> ResourceRecord { + ResourceRecord::new(*id, self.id_v1_scope(id, res), res.clone()) + } + + pub fn get_resource(&self, rlink: &ResourceLink) -> HueResult { + self.state + .res + .get(&rlink.rid) + .filter(|res| res.rtype() == rlink.rtype) + .map(|res| self.make_resource_record(&rlink.rid, res)) + .ok_or(HueError::NotFound(rlink.rid)) + } + + pub fn get_resource_by_id(&self, id: &Uuid) -> HueResult { + self.state + .get(id) + .map(|res| self.make_resource_record(id, res)) + } + + #[must_use] + pub fn get_resources(&self) -> Vec { + self.state + .res + .iter() + .map(|(id, res)| self.make_resource_record(id, res)) + .collect() + } + + #[must_use] + pub fn get_resources_by_type(&self, ty: RType) -> Vec { + self.state + .res + .iter() + .filter(|(_, r)| r.rtype() == ty) + .map(|(id, res)| self.make_resource_record(id, res)) + .collect() + } + + #[must_use] + pub fn get_resource_ids_by_type(&self, ty: RType) -> Vec { + self.state + .res + .iter() + .filter(|(_, r)| r.rtype() == ty) + .map(|(id, _res)| *id) + .collect() + } + + #[must_use] + pub fn get_resources_by_owner(&self, owner: ResourceLink) -> Vec { + self.state + .res + .iter() + .filter(|(_, r)| r.owner() == Some(owner)) + .map(|(id, res)| self.make_resource_record(id, res)) + .collect() + } + + pub fn get_id_v1_index(&self, uuid: Uuid) -> HueResult { + self.state.id_v1(&uuid).ok_or(HueError::NotFound(uuid)) + } + + pub fn get_id_v1(&self, uuid: Uuid) -> HueResult { + Ok(self.get_id_v1_index(uuid)?.to_string()) + } + + pub fn from_id_v1(&self, id: u32) -> HueResult { + self.state.from_id_v1(&id).ok_or(HueError::V1NotFound(id)) + } + + #[must_use] + pub fn state_channel(&self) -> Arc { + self.state_updates.clone() + } + + #[must_use] + pub const fn hue_event_stream(&self) -> &HueEventStream { + &self.hue_event_stream + } + + #[must_use] + pub fn backend_event_stream(&self) -> Receiver> { + self.backend_updates.subscribe() + } + + pub fn backend_request(&self, req: BackendRequest) -> ApiResult<()> { + if !matches!(req, BackendRequest::EntertainmentFrame(_)) { + log::debug!("Backend request: {req:#?}"); + } + + self.backend_updates.send(Arc::new(req))?; + + Ok(()) + } +} diff --git a/src/routes/api.rs b/src/routes/api.rs new file mode 100644 index 0000000..a8e8158 --- /dev/null +++ b/src/routes/api.rs @@ -0,0 +1,583 @@ +use std::collections::{BTreeMap, HashMap}; + +use axum::Router; +use axum::extract::{Path, State}; +use axum::routing::{get, post, put}; +use bytes::Bytes; +use chrono::Utc; +use log::{info, warn}; +use serde::Serialize; +use serde_json::{Value, json}; +use tokio::sync::MutexGuard; + +use bifrost_api::backend::BackendRequest; +use hue::api::{ + Device, Entertainment, EntertainmentConfiguration, EntertainmentConfigurationAction, + EntertainmentConfigurationLocationsNew, EntertainmentConfigurationMetadata, + EntertainmentConfigurationNew, EntertainmentConfigurationServiceLocationsNew, + EntertainmentConfigurationType, EntertainmentConfigurationUpdate, GroupedLight, + GroupedLightUpdate, Light, LightUpdate, RType, ResourceLink, Room, Scene, SceneActive, + SceneStatus, SceneUpdate, V1Reply, +}; +use hue::error::{HueApiV1Error, HueError, HueResult}; +use hue::legacy_api::{ + ApiGroup, ApiGroupAction, ApiGroupActionUpdate, ApiGroupClass, ApiGroupNew, ApiGroupState, + ApiGroupType, ApiGroupUpdate2, ApiLight, ApiLightStateUpdate, ApiResourceType, ApiScene, + ApiSceneAppData, ApiSceneType, ApiSceneVersion, ApiSensor, ApiUserConfig, Capabilities, + HueApiResult, NewUser, NewUserReply, +}; + +use crate::error::{ApiError, ApiResult}; +use crate::resource::Resources; +use crate::routes::auth::{STANDARD_APPLICATION_ID, STANDARD_CLIENT_KEY}; +use crate::routes::clip::entertainment_configuration::{self, POSITIONS}; +use crate::routes::extractor::Json; +use crate::routes::{ApiV1Error, ApiV1Result}; +use crate::server::appstate::AppState; + +async fn get_api_config(State(state): State) -> Json { + Json(state.api_short_config().await) +} + +async fn post_api(bytes: Bytes) -> ApiV1Result> { + info!("post: {bytes:?}"); + let json: NewUser = serde_json::from_slice(&bytes)?; + + let res = NewUserReply { + clientkey: if json.generateclientkey { + Some(hex::encode_upper(STANDARD_CLIENT_KEY)) + } else { + None + }, + username: STANDARD_APPLICATION_ID.to_string(), + }; + Ok(Json(vec![HueApiResult::Success(res)])) +} + +fn get_lights(res: &MutexGuard) -> ApiResult> { + let mut lights = HashMap::new(); + + for rr in res.get_resources_by_type(RType::Light) { + let light: Light = rr.obj.try_into()?; + let dev = res.get::(&light.owner)?; + lights.insert( + res.get_id_v1(rr.id)?, + ApiLight::from_dev_and_light(&rr.id, dev, &light), + ); + } + + Ok(lights) +} + +fn get_groups(res: &MutexGuard, group_0: bool) -> ApiResult> { + let mut rooms = HashMap::new(); + + if group_0 { + rooms.insert("0".into(), ApiGroup::make_group_0()); + } + + for rr in res.get_resources_by_type(RType::Room) { + let room: Room = rr.obj.try_into()?; + let uuid = room + .services + .iter() + .find(|rl| rl.rtype == RType::GroupedLight) + .ok_or(HueError::NotFound(rr.id))?; + + let glight = res.get::(uuid)?; + let lights: Vec = room + .children + .iter() + .filter_map(|rl| res.get(rl).ok()) + .filter_map(Device::light_service) + .filter_map(|rl| res.get_id_v1(rl.rid).ok()) + .collect(); + + rooms.insert( + res.get_id_v1(rr.id)?, + ApiGroup::from_lights_and_room(glight, lights, room), + ); + } + + for rr in res.get_resources_by_type(RType::EntertainmentConfiguration) { + let entconf: EntertainmentConfiguration = rr.obj.try_into()?; + + let mut locations = BTreeMap::>::new(); + + for sl in &entconf.locations.service_locations { + let ent = res.get::(&sl.service)?; + let dev = res.get::(&ent.owner)?; + let light_link = dev + .light_service() + .ok_or(HueError::NotFound(ent.owner.rid))?; + + let idx = res.get_id_v1_index(light_link.rid)?; + locations.insert( + idx.to_string(), + vec![sl.position.x, sl.position.y, sl.position.z], + ); + } + + let class = match entconf.configuration_type { + EntertainmentConfigurationType::Screen => ApiGroupClass::TV, + EntertainmentConfigurationType::Monitor => ApiGroupClass::Computer, + EntertainmentConfigurationType::Music => ApiGroupClass::Music, + // FIXME: what does Space3D map to? + EntertainmentConfigurationType::Space3D | EntertainmentConfigurationType::Other => { + ApiGroupClass::Other + } + }; + + rooms.insert( + res.get_id_v1(rr.id)?, + ApiGroup { + name: entconf.metadata.name.clone(), + lights: locations.keys().cloned().collect(), + locations: json!(locations), + action: ApiGroupAction::default(), + class, + group_type: ApiGroupType::Entertainment, + recycle: false, + sensors: vec![], + state: ApiGroupState::default(), + stream: json!({ + "active": entconf.active_streamer.is_some(), + "owner": entconf.active_streamer.map(|_st| STANDARD_APPLICATION_ID.to_string()), + "proxymode": "auto", + "proxynode": "/bridge" + }), + }, + ); + } + + Ok(rooms) +} + +pub fn get_scene(res: &Resources, owner: String, scene: &Scene) -> ApiV1Result { + let lights = scene + .actions + .iter() + .map(|sae| res.get_id_v1(sae.target.rid)) + .collect::>()?; + + let lightstates = scene + .actions + .iter() + .map(|sae| { + Ok(( + res.get_id_v1(sae.target.rid)?, + ApiLightStateUpdate::from(sae.action.clone()), + )) + }) + .collect::>()?; + + let room_id = res.get_id_v1_index(scene.group.rid)?; + + Ok(ApiScene { + name: scene.metadata.name.clone(), + scene_type: ApiSceneType::GroupScene, + lights, + lightstates, + owner, + recycle: false, + locked: false, + /* Some clients (e.g. Hue Essentials) require .appdata */ + appdata: ApiSceneAppData { + data: Some(format!("xxxxx_r{room_id}")), + version: Some(1), + }, + picture: String::new(), + lastupdated: Utc::now(), + version: ApiSceneVersion::V2 as u32, + image: scene.metadata.image.map(|rl| rl.rid), + group: Some(room_id.to_string()), + }) +} + +fn get_scenes(owner: &str, res: &MutexGuard) -> ApiV1Result> { + let mut scenes = HashMap::new(); + + for rr in res.get_resources_by_type(RType::Scene) { + let scene = &rr.obj.try_into()?; + + scenes.insert( + res.get_id_v1(rr.id)?, + get_scene(res, owner.to_string(), scene)?, + ); + } + + Ok(scenes) +} + +#[allow(clippy::zero_sized_map_values)] +async fn get_api_user( + state: State, + Path(username): Path, +) -> ApiV1Result> { + let lock = state.res.lock().await; + + Ok(Json(ApiUserConfig { + config: state.api_config(username.clone()).await?, + groups: get_groups(&lock, false)?, + lights: get_lights(&lock)?, + resourcelinks: HashMap::new(), + rules: HashMap::new(), + scenes: get_scenes(&username, &lock)?, + schedules: HashMap::new(), + sensors: HashMap::from([(1, ApiSensor::builtin_daylight_sensor())]), + })) +} + +async fn get_api_user_resource( + State(state): State, + Path((username, artype)): Path<(String, ApiResourceType)>, +) -> ApiV1Result> { + let lock = &state.res.lock().await; + match artype { + ApiResourceType::Config => Ok(Json(json!(state.api_config(username).await?))), + ApiResourceType::Lights => Ok(Json(json!(get_lights(lock)?))), + ApiResourceType::Groups => Ok(Json(json!(get_groups(lock, false)?))), + ApiResourceType::Scenes => Ok(Json(json!(get_scenes(&username, lock)?))), + ApiResourceType::Resourcelinks + | ApiResourceType::Rules + | ApiResourceType::Schedules + | ApiResourceType::Sensors => Ok(Json(json!({}))), + ApiResourceType::Capabilities => Ok(Json(json!(Capabilities::new()))), + } +} + +fn lights_v1_to_ec_locations( + lights: &[String], + res: &Resources, +) -> ApiResult { + let mut service_locations = vec![]; + + let mut positions = POSITIONS.iter().cycle(); + + for id in lights { + let light_uuid = res.from_id_v1(id.parse().map_err(ApiError::ParseIntError)?)?; + let light = res.get_id::(light_uuid)?; + let device = res.get::(&light.owner)?; + + // FIXME: not the best error mapping + let ent_svc = device + .entertainment_service() + .ok_or(HueError::NotFound(light_uuid))?; + + service_locations.push(EntertainmentConfigurationServiceLocationsNew { + positions: vec![positions.next().unwrap().clone()], + service: *ent_svc, + }); + } + + Ok(EntertainmentConfigurationLocationsNew { service_locations }) +} + +async fn post_api_user_resource( + state: State, + Path((_username, resource)): Path<(String, ApiResourceType)>, + Json(req): Json, +) -> ApiV1Result> { + // FIXME: these are copied from entertainment_configuration + + // We only know how to create entertainment groups + let ApiResourceType::Groups = resource else { + warn!("POST v1 user resource unsupported"); + warn!("Request: {req:?}"); + return Err(ApiV1Error::V1CreateUnsupported(resource)); + }; + + let group_create: ApiGroupNew = serde_json::from_value(req)?; + info!("Create group request: {group_create:?}"); + + if group_create.group_type != ApiGroupType::Entertainment { + return Err(ApiV1Error::V1CreateUnsupported(resource)); + } + + let lock = state.res.lock().await; + + let locations = lights_v1_to_ec_locations(&group_create.lights, &lock)?; + + let ecnew = EntertainmentConfigurationNew { + configuration_type: EntertainmentConfigurationType::Screen, + metadata: EntertainmentConfigurationMetadata { + name: group_create + .name + .unwrap_or_else(|| String::from("Entertainment area")), + }, + stream_proxy: None, + locations, + }; + + log::debug!("Converted to V2 create request: {ecnew:?}"); + drop(lock); + + let mut resp = + entertainment_configuration::post_resource(&state, serde_json::to_value(ecnew)?).await?; + + // FIXME: ugly unpacking/repacking of post_resource result + if let Some(data) = resp.0.data.pop() { + let rlink: ResourceLink = serde_json::from_value(data)?; + + let id = state.res.lock().await.get_id_v1_index(rlink.rid)?; + + let response = json!([{"success": {"id": id}}]); + + log::info!("Success: created {id} ({})", rlink.rid); + Ok(Json(response)) + } else { + Err(ApiV1Error::V1CreateUnsupported(resource)) + } +} + +async fn put_api_user_resource( + Path((_username, _resource)): Path<(String, String)>, + Json(req): Json, +) -> ApiV1Result> { + warn!("PUT v1 user resource {req:?}"); + //Json(format!("user {username} resource {resource}")) + Ok(Json(vec![HueApiResult::Success(req)])) +} + +#[allow(clippy::significant_drop_tightening)] +async fn get_api_user_resource_id( + State(state): State, + Path((username, resource, id)): Path<(String, ApiResourceType, u32)>, +) -> ApiV1Result> { + log::debug!("GET v1 username={username} resource={resource:?} id={id}"); + let result = match resource { + ApiResourceType::Lights => { + let lock = state.res.lock().await; + let uuid = lock.from_id_v1(id)?; + let link = ResourceLink::new(uuid, RType::Light); + let light = lock.get::(&link)?; + let dev = lock.get::(&light.owner)?; + + json!(ApiLight::from_dev_and_light(&uuid, dev, light)) + } + ApiResourceType::Scenes => { + let lock = state.res.lock().await; + let uuid = lock.from_id_v1(id)?; + let link = ResourceLink::new(uuid, RType::Scene); + let scene = lock.get::(&link)?; + + json!(get_scene(&lock, username, scene)?) + } + ApiResourceType::Groups => { + let lock = state.res.lock().await; + let groups = get_groups(&lock, true)?; + let group = groups + .get(&id.to_string()) + .ok_or(HueError::V1NotFound(id))?; + + json!(group) + } + _ => Err(HueError::V1NotFound(id))?, + }; + + Ok(Json(result)) +} + +#[allow(clippy::significant_drop_tightening, clippy::single_match)] +async fn put_api_user_resource_id( + State(state): State, + Path((username, artype, id)): Path<(String, ApiResourceType, u32)>, + Json(req): Json, +) -> ApiV1Result> { + log::debug!("PUT v1 username={username} resource={artype:?} id={id}"); + log::debug!("JSON: {req:?}"); + match artype { + ApiResourceType::Groups => { + let upd: ApiGroupUpdate2 = serde_json::from_value(req)?; + + let mut v1res = V1Reply::for_group(id); + + let mut ecupd = EntertainmentConfigurationUpdate::new(); + + let lock = state.res.lock().await; + + let uuid = lock.from_id_v1(id)?; + + ecupd.action = upd.stream.map(|stream| { + if stream.active { + EntertainmentConfigurationAction::Start + } else { + EntertainmentConfigurationAction::Stop + } + }); + + if let Some(lights) = &upd.lights { + ecupd.locations = Some(lights_v1_to_ec_locations(lights, &lock)?.into()); + } + + drop(lock); + + let rlink = RType::EntertainmentConfiguration.link_to(uuid); + + let resp = entertainment_configuration::put_resource_id( + &state, + rlink, + serde_json::to_value(&ecupd)?, + ) + .await?; + + if !resp.0.errors.is_empty() { + Err(HueApiV1Error::BridgeInternalError)?; + } + + if let Some(stream) = &upd.stream { + v1res = v1res.add("stream/active", stream.active)?; + } + + Ok(Json(v1res.json())) + } + ApiResourceType::Config + | ApiResourceType::Lights + | ApiResourceType::Resourcelinks + | ApiResourceType::Rules + | ApiResourceType::Scenes + | ApiResourceType::Schedules + | ApiResourceType::Sensors + | ApiResourceType::Capabilities => Err(ApiV1Error::V1CreateUnsupported(artype)), + } +} + +async fn put_api_user_resource_id_path( + State(state): State, + Path((_username, artype, id, path)): Path<(String, ApiResourceType, u32, String)>, + Json(req): Json, +) -> ApiV1Result> { + match artype { + ApiResourceType::Lights => { + log::debug!("req: {}", serde_json::to_string_pretty(&req)?); + if path != "state" { + return Err(HueError::V1NotFound(id))?; + } + + let lock = state.res.lock().await; + let uuid = lock.from_id_v1(id)?; + let link = ResourceLink::new(uuid, RType::Light); + let updv1: ApiLightStateUpdate = serde_json::from_value(req)?; + + let upd = LightUpdate::from(&updv1); + + lock.backend_request(BackendRequest::LightUpdate(link, upd))?; + drop(lock); + + let reply = V1Reply::for_light(id, &path).with_light_state_update(&updv1)?; + + Ok(Json(reply.json())) + } + + /* handle groups, exceot for group 0 ("all groups") */ + ApiResourceType::Groups if id != 0 => { + if path != "action" { + return Err(HueError::V1NotFound(id))?; + } + + let lock = state.res.lock().await; + + let uuid = lock.from_id_v1(id)?; + let link = ResourceLink::new(uuid, RType::Room); + + let room: &Room = lock.get(&link)?; + let glight = room.grouped_light_service().unwrap(); + + let updv1: ApiGroupActionUpdate = serde_json::from_value(req)?; + + let reply = match updv1 { + ApiGroupActionUpdate::LightUpdate(upd) => { + let updv2 = GroupedLightUpdate::from(&upd); + + lock.backend_request(BackendRequest::GroupedLightUpdate(*glight, updv2))?; + drop(lock); + + V1Reply::for_group_path(id, &path).with_light_state_update(&upd)? + } + ApiGroupActionUpdate::GroupUpdate(upd) => { + let scene_id = upd.scene.parse().map_err(ApiError::ParseIntError)?; + let scene_uuid = lock.from_id_v1(scene_id)?; + let rlink = RType::Scene.link_to(scene_uuid); + let updv2 = SceneUpdate::new().with_recall_action(Some(SceneStatus { + active: SceneActive::Static, + last_recall: None, + })); + lock.backend_request(BackendRequest::SceneUpdate(rlink, updv2))?; + drop(lock); + + V1Reply::for_group_path(id, &path).add("scene", upd.scene)? + } + }; + + Ok(Json(reply.json())) + } + + /* handle group 0 ("all groups") */ + ApiResourceType::Groups => { + if path != "action" { + return Err(HueError::V1NotFound(id))?; + } + + let lock = state.res.lock().await; + + let updv1: ApiGroupActionUpdate = serde_json::from_value(req)?; + + let reply = match updv1 { + ApiGroupActionUpdate::LightUpdate(upd) => { + let updv2 = GroupedLightUpdate::from(&upd); + + for res in lock.get_resources_by_type(RType::GroupedLight) { + let link = RType::GroupedLight.link_to(res.id); + let req = BackendRequest::GroupedLightUpdate(link, updv2.clone()); + lock.backend_request(req)?; + } + + drop(lock); + + V1Reply::for_group_path(id, &path).with_light_state_update(&upd)? + } + ApiGroupActionUpdate::GroupUpdate(_api_group_update) => { + return Err(HueError::V1NotFound(id))?; + } + }; + + Ok(Json(reply.json())) + } + + ApiResourceType::Config + | ApiResourceType::Resourcelinks + | ApiResourceType::Rules + | ApiResourceType::Scenes + | ApiResourceType::Schedules + | ApiResourceType::Sensors + | ApiResourceType::Capabilities => Err(ApiV1Error::V1CreateUnsupported(artype)), + } +} + +/// This generates a workaround necessary for iConnectHue (iPhone app) +/// +/// For some reason, iConnectHue has been observed to try the endpoint GET /api/newUser, +/// even though this does not seem to ever have been a valid hue endpoint. +/// +/// 2025-01-24: This response has been confirmed to work by Alexa and Peter Miller on discord. +pub async fn workaround_iconnect_hue() -> ApiV1Result<()> { + Err(HueApiV1Error::UnauthorizedUser)? +} + +pub fn router() -> Router { + Router::new() + .route("/", post(post_api)) + .route("/config", get(get_api_config)) + .route("/nouser/config", get(get_api_config)) + .route("/newUser", get(workaround_iconnect_hue)) + .route("/{user}", get(get_api_user)) + .route("/{user}/{rtype}", get(get_api_user_resource)) + .route("/{user}/{rtype}", post(post_api_user_resource)) + .route("/{user}/{rtype}", put(put_api_user_resource)) + .route("/{user}/{rtype}/{id}", get(get_api_user_resource_id)) + .route("/{user}/{rtype}/{id}", put(put_api_user_resource_id)) + .route( + "/{user}/{rtype}/{id}/{key}", + put(put_api_user_resource_id_path), + ) +} diff --git a/src/routes/auth.rs b/src/routes/auth.rs new file mode 100644 index 0000000..a1db526 --- /dev/null +++ b/src/routes/auth.rs @@ -0,0 +1,29 @@ +use axum::Router; +use axum::http::HeaderValue; +use axum::response::IntoResponse; +use axum::routing::get; +use hyper::HeaderMap; +use serde_json::json; + +use hue::api::HueStreamKey; + +use crate::routes::extractor::Json; +use crate::server::appstate::AppState; + +pub const STANDARD_APPLICATION_ID: &str = "01010101-0202-0303-0404-050505050505"; + +/// This 16-byte key is used for all DTLS entertainment streams +pub const STANDARD_CLIENT_KEY: HueStreamKey = HueStreamKey::new(*b"BifrostHueTlsKey"); + +pub async fn auth_v1() -> impl IntoResponse { + let value = HeaderValue::from_static(STANDARD_APPLICATION_ID); + + let mut headers = HeaderMap::new(); + headers.append("hue-application-id", value); + + (headers, Json(json!({}))) +} + +pub fn router() -> Router { + Router::new().route("/v1", get(auth_v1)) +} diff --git a/src/routes/bifrost/backend.rs b/src/routes/bifrost/backend.rs new file mode 100644 index 0000000..cb1e416 --- /dev/null +++ b/src/routes/bifrost/backend.rs @@ -0,0 +1,33 @@ +use axum::Router; +use axum::extract::{Path, State}; +use axum::routing::post; + +use bifrost_api::config::Z2mServer; + +use crate::backend::z2m::Z2mBackend; +use crate::routes::bifrost::BifrostApiResult; +use crate::routes::extractor::Json; +use crate::server::appstate::AppState; + +#[axum::debug_handler] +async fn post_backend_z2m( + State(state): State, + Path(name): Path, + Json(server): Json, +) -> BifrostApiResult> { + log::info!("Adding new z2m backend: {name:?}"); + + let mut mgr = state.manager(); + + let svc = Z2mBackend::new(name.clone(), server, state.config(), state.res.clone())?; + let name = format!("z2m-{name}"); + + mgr.register_service(&name, svc).await?; + mgr.start(&name).await?; + + Ok(Json(())) +} + +pub fn router() -> Router { + Router::new().route("/z2m/{name}", post(post_backend_z2m)) +} diff --git a/src/routes/bifrost/mod.rs b/src/routes/bifrost/mod.rs new file mode 100644 index 0000000..35e712e --- /dev/null +++ b/src/routes/bifrost/mod.rs @@ -0,0 +1,58 @@ +pub mod backend; +pub mod service; +pub mod websocket; + +use std::error::Error; + +use axum::Router; +use axum::extract::State; +use axum::response::{IntoResponse, Response}; +use axum::routing::{any, get}; +use hyper::StatusCode; +use serde::Serialize; +use serde_json::json; + +use bifrost_api::config::AppConfig; + +use crate::routes::bifrost::websocket::websocket; +use crate::routes::extractor::Json; +use crate::server::appstate::AppState; + +#[derive(Debug, Serialize)] +/// Simple bifrost api error wrapper. +/// +/// Bifrost API results need to implement [`IntoResponse`], but since +/// [`BifrostError`] comes from [`bifrost_api`], we can't implement +/// [`IntoResponse`] for it, without gaining a dependency on [`axum`] for that +/// crate. So for now, we use this thin wrapper for an [`IntoResponse`] impl. +struct BifrostApiError(String); + +type BifrostApiResult = Result; + +impl From for BifrostApiError { + fn from(value: E) -> Self { + Self(value.to_string()) + } +} + +impl IntoResponse for BifrostApiError { + fn into_response(self) -> Response { + log::error!("Request failed: {}", self.0); + + let res = json!({"error": self.0}); + + (StatusCode::INTERNAL_SERVER_ERROR, Json(res)).into_response() + } +} + +async fn get_config(State(state): State) -> BifrostApiResult> { + Ok(Json((*state.config()).clone())) +} + +pub fn router() -> Router { + Router::new() + .nest("/service", service::router()) + .nest("/backend", backend::router()) + .route("/config", get(get_config)) + .route("/ws", any(websocket)) +} diff --git a/src/routes/bifrost/service.rs b/src/routes/bifrost/service.rs new file mode 100644 index 0000000..f824273 --- /dev/null +++ b/src/routes/bifrost/service.rs @@ -0,0 +1,57 @@ +use std::collections::BTreeMap; + +use axum::Router; +use axum::extract::{Path, State}; +use axum::routing::{get, put}; +use uuid::Uuid; + +use bifrost_api::service::{Service, ServiceList}; +use svc::traits::ServiceState; + +use crate::routes::bifrost::BifrostApiResult; +use crate::routes::extractor::Json; +use crate::server::appstate::AppState; + +async fn service_list(state: &AppState) -> BifrostApiResult { + let mut svm = state.manager(); + + let mut services = BTreeMap::new(); + for (id, name) in svm.list().await? { + let state = svm.status(id).await?; + + let service = Service { id, name, state }; + services.insert(id, service); + } + + Ok(ServiceList { services }) +} + +async fn get_services(State(state): State) -> BifrostApiResult> { + Ok(Json(service_list(&state).await?)) +} + +async fn put_service( + State(state): State, + Path(id): Path, + Json(service_state): Json, +) -> BifrostApiResult> { + let mut mgr = state.manager(); + + let uuid = match service_state { + ServiceState::Registered + | ServiceState::Configured + | ServiceState::Starting + | ServiceState::Stopping + | ServiceState::Failed => mgr.resolve(id).await?, + ServiceState::Running => mgr.start(id).await?, + ServiceState::Stopped => mgr.stop(id).await?, + }; + + Ok(Json(uuid)) +} + +pub fn router() -> Router { + Router::new() + .route("/", get(get_services)) + .route("/{id}", put(put_service)) +} diff --git a/src/routes/bifrost/websocket.rs b/src/routes/bifrost/websocket.rs new file mode 100644 index 0000000..a967979 --- /dev/null +++ b/src/routes/bifrost/websocket.rs @@ -0,0 +1,123 @@ +use std::sync::Arc; + +use axum::extract::ws::{Message, WebSocket}; +use axum::extract::{State, WebSocketUpgrade}; +use axum::response::Response; +use tokio::select; + +use bifrost_api::backend::BackendRequest; +use bifrost_api::service::Service; +use bifrost_api::websocket::Update; +use hue::event::EventBlock; +use svc::manager::{ServiceEvent, SvmClient}; + +use crate::routes::bifrost::BifrostApiResult; +use crate::server::appstate::AppState; +use crate::server::hueevents::HueEventRecord; + +struct WebSocketTask { + state: AppState, + ws: WebSocket, + mgr: SvmClient, +} + +#[allow(clippy::unnecessary_wraps, clippy::unused_self)] +impl WebSocketTask { + pub fn new(state: AppState, ws: WebSocket) -> Self { + let mgr = state.manager(); + + Self { state, ws, mgr } + } + + async fn send(&mut self, value: Update) -> BifrostApiResult<()> { + let text = serde_json::to_string(&value)?.into(); + + self.ws.send(Message::Text(text)).await?; + + Ok(()) + } + + fn handle_websocket_message(&self, msg: &Message) -> BifrostApiResult> { + log::trace!("Websocket message: {msg:?}"); + Ok(None) + } + + fn handle_backend_event( + &self, + backend_event: &Arc, + ) -> BifrostApiResult> { + log::info!("Backend event: {backend_event:?}"); + Ok(Some(Update::BackendRequest((**backend_event).clone()))) + } + + fn handle_hue_event(&self, hue_event: HueEventRecord) -> BifrostApiResult> { + log::info!("Hue event: {hue_event:?}"); + Ok(Some(Update::HueEvent(hue_event.block))) + } + + async fn handle_service_event( + &mut self, + service_event: Option, + ) -> BifrostApiResult> { + let Some(service_event) = service_event else { + log::error!("service event channel broke"); + panic!(); + }; + + log::trace!("service event: {service_event:?}"); + + let name = self.mgr.lookup_name(service_event.id()).await?; + + let service = Service { + id: service_event.id(), + name, + state: service_event.state(), + }; + + Ok(Some(Update::ServiceUpdate(service))) + } + + async fn handle_socket(mut self) -> BifrostApiResult<()> { + let lock = self.state.res.lock().await; + let mut backend_events = lock.backend_event_stream(); + let mut hue_events = lock.hue_event_stream().subscribe(); + let hue_state = lock.get_resources(); + drop(lock); + + let mut svc_events = self.mgr.subscribe().await?.1; + + let app_config = self.state.config(); + self.send(Update::AppConfig((*app_config).clone())).await?; + + self.send(Update::HueEvent(EventBlock::add(hue_state))) + .await?; + + loop { + let reply = select! { + Some(msg) = self.ws.recv() => self.handle_websocket_message(&msg?), + backend_event = backend_events.recv() => self.handle_backend_event(&backend_event?), + service_event = svc_events.recv() => self.handle_service_event(service_event).await, + hue_event = hue_events.recv() => self.handle_hue_event(hue_event?), + }; + + if let Some(reply) = reply? { + self.send(reply).await?; + } + } + } +} + +pub async fn websocket(State(state): State, ws: WebSocketUpgrade) -> Response { + ws.on_upgrade(|ws| async move { + let wst = WebSocketTask::new(state, ws); + + match wst.handle_socket().await { + Ok(()) => { + log::info!("Websocket closed."); + } + Err(err) => { + log::error!("Websocket error: {err:?}"); + } + } + }) +} diff --git a/src/routes/clip/device.rs b/src/routes/clip/device.rs new file mode 100644 index 0000000..8ce05cc --- /dev/null +++ b/src/routes/clip/device.rs @@ -0,0 +1,27 @@ +use bifrost_api::backend::BackendRequest; +use serde_json::Value; + +use hue::api::{Device, DeviceUpdate, LightUpdate, ResourceLink}; + +use crate::routes::V2Reply; +use crate::routes::clip::ApiV2Result; +use crate::server::appstate::AppState; + +pub async fn put_device(state: &AppState, rlink: ResourceLink, put: Value) -> ApiV2Result { + let upd: DeviceUpdate = serde_json::from_value(put)?; + + let mut lock = state.res.lock().await; + + if let Some(identify) = &upd.identify { + let dev: &Device = lock.get(&rlink)?; + if let Some(light) = dev.light_service() { + let upd = LightUpdate::new().with_identify(Some(*identify)); + lock.backend_request(BackendRequest::LightUpdate(*light, upd))?; + } + } + + lock.update::(&rlink.rid, |obj| *obj += &upd)?; + drop(lock); + + V2Reply::ok(rlink) +} diff --git a/src/routes/clip/entertainment_configuration.rs b/src/routes/clip/entertainment_configuration.rs new file mode 100644 index 0000000..73fa521 --- /dev/null +++ b/src/routes/clip/entertainment_configuration.rs @@ -0,0 +1,274 @@ +use hue::error::HueError; +use serde_json::Value; +use uuid::{Uuid, uuid}; + +use hue::api::{ + Bridge, Device, Entertainment, EntertainmentConfiguration, EntertainmentConfigurationAction, + EntertainmentConfigurationChannels, EntertainmentConfigurationLocations, + EntertainmentConfigurationNew, EntertainmentConfigurationServiceLocations, + EntertainmentConfigurationStatus, EntertainmentConfigurationStreamMembers, + EntertainmentConfigurationStreamProxy, EntertainmentConfigurationStreamProxyMode, + EntertainmentConfigurationStreamProxyUpdate, EntertainmentConfigurationUpdate, Light, + LightMode, Position, RType, Resource, ResourceLink, +}; + +use crate::error::ApiResult; +use crate::resource::Resources; +use crate::routes::auth::STANDARD_APPLICATION_ID; +use crate::routes::clip::{ApiV2Result, V2Reply}; +use crate::server::appstate::AppState; + +// FIXME: These are hard-coded values fitting for an LCX005 gradient light chain +pub const POSITIONS: &[Position] = &[ + Position { + x: -0.4, + y: 0.8, + z: -0.4, + }, + Position { + x: -0.4, + y: 0.8, + z: 0.4, + }, + Position { + x: -0.22, + y: 0.8, + z: 0.4, + }, + Position { + x: 0.0, + y: 0.8, + z: 0.4, + }, + Position { + x: 0.22, + y: 0.8, + z: 0.4, + }, + Position { + x: 0.4, + y: 0.8, + z: 0.4, + }, + Position { + x: 0.4, + y: 0.8, + z: -0.4, + }, +]; + +pub async fn post_resource(state: &AppState, req: Value) -> ApiV2Result { + let new: EntertainmentConfigurationNew = serde_json::from_value(req)?; + + let mut lock = state.res.lock().await; + + let locations = EntertainmentConfigurationLocations { + service_locations: new + .locations + .service_locations + .into_iter() + .map(Into::into) + .collect(), + }; + + let channels = make_channels(&lock, &locations.service_locations)?; + let light_services = make_services(&lock, &locations.service_locations)?; + + let auto_node = find_bridge_entertainment(&lock)?; + + let obj = Resource::EntertainmentConfiguration(EntertainmentConfiguration { + name: new.metadata.name.clone(), + configuration_type: new.configuration_type, + metadata: new.metadata, + status: EntertainmentConfigurationStatus::Inactive, + stream_proxy: new.stream_proxy.map_or_else( + || EntertainmentConfigurationStreamProxy { + mode: EntertainmentConfigurationStreamProxyMode::Auto, + node: auto_node, + }, + |sp| match sp { + EntertainmentConfigurationStreamProxyUpdate::Auto => { + EntertainmentConfigurationStreamProxy { + mode: EntertainmentConfigurationStreamProxyMode::Auto, + node: auto_node, + } + } + EntertainmentConfigurationStreamProxyUpdate::Manual { node } => { + EntertainmentConfigurationStreamProxy { + mode: EntertainmentConfigurationStreamProxyMode::Manual, + node, + } + } + }, + ), + channels, + locations, + light_services, + active_streamer: None, + }); + + let rlink = ResourceLink::new(Uuid::new_v4(), obj.rtype()); + lock.add(&rlink, obj)?; + drop(lock); + + V2Reply::ok(rlink) +} + +fn make_channels( + lock: &Resources, + locations: &[EntertainmentConfigurationServiceLocations], +) -> ApiResult> { + let mut channels: Vec = vec![]; + + let mut channel_id = 0; + for location in locations { + let Some(pos) = location.positions.first() else { + continue; + }; + let ent: &Entertainment = lock.get(&location.service)?; + + if let Some(segs) = &ent.segments { + for index in 0..segs.segments.len() { + channels.push(EntertainmentConfigurationChannels { + channel_id, + position: POSITIONS[index % POSITIONS.len()].clone(), + members: vec![EntertainmentConfigurationStreamMembers { + service: location.service, + index: u16::try_from(index)?, + }], + }); + channel_id += 1; + } + } else { + channels.push(EntertainmentConfigurationChannels { + channel_id, + position: pos.clone(), + members: vec![EntertainmentConfigurationStreamMembers { + service: location.service, + index: 0, + }], + }); + channel_id += 1; + } + } + + Ok(channels) +} + +fn make_services( + lock: &Resources, + locations: &[EntertainmentConfigurationServiceLocations], +) -> ApiResult> { + let mut res = vec![]; + + for location in locations { + let ent: &Entertainment = lock.get(&location.service)?; + if let Some(ren) = ent.renderer_reference { + res.push(ren); + } + } + + Ok(res) +} + +fn find_bridge_entertainment(lock: &Resources) -> ApiResult { + let bridge_id = lock.get_resource_ids_by_type(RType::Bridge)[0]; + + let bridge: &Bridge = lock.get_id(bridge_id)?; + + let bridge_dev: &Device = lock.get_id(bridge.owner.rid)?; + + let bridge_ent = bridge_dev + .services + .iter() + .find(|obj| obj.rtype == RType::Entertainment) + .copied() + .ok_or(HueError::NotFound(bridge_id))?; + + Ok(bridge_ent) +} + +pub async fn put_resource_id(state: &AppState, rlink: ResourceLink, put: Value) -> ApiV2Result { + let upd: EntertainmentConfigurationUpdate = serde_json::from_value(put)?; + + let mut lock = state.res.lock().await; + + let mut locations = None; + let mut channels = vec![]; + let mut light_services = vec![]; + + if let Some(locs) = &upd.locations { + let newlocs = EntertainmentConfigurationLocations { + service_locations: locs + .service_locations + .clone() + .into_iter() + .map(Into::into) + .collect(), + }; + channels = make_channels(&lock, &newlocs.service_locations)?; + light_services = make_services(&lock, &newlocs.service_locations)?; + locations = Some(newlocs); + } + + if let Some(action) = &upd.action { + let ent: &EntertainmentConfiguration = lock.get(&rlink)?; + let svc = ent.light_services.clone(); + + lock.update::(&svc[0].rid, |light| { + light.mode = match action { + EntertainmentConfigurationAction::Start => LightMode::Streaming, + EntertainmentConfigurationAction::Stop => LightMode::Normal, + } + })?; + } + + let bridge_ent = find_bridge_entertainment(&lock)?; + + lock.update::(&rlink.rid, |ec| { + if let Some(_locations) = upd.locations { + ec.locations = locations.unwrap(); + ec.channels = channels; + ec.light_services = light_services; + } + + if let Some(proxy) = upd.stream_proxy { + match proxy { + EntertainmentConfigurationStreamProxyUpdate::Auto => { + ec.stream_proxy.mode = EntertainmentConfigurationStreamProxyMode::Auto; + ec.stream_proxy.node = bridge_ent; + } + EntertainmentConfigurationStreamProxyUpdate::Manual { node } => { + ec.stream_proxy.mode = EntertainmentConfigurationStreamProxyMode::Manual; + ec.stream_proxy.node = node; + } + } + } + + if let Some(ctype) = upd.configuration_type { + ec.configuration_type = ctype; + } + + if let Some(md) = upd.metadata { + ec.metadata = md; + } + + if let Some(action) = upd.action { + match action { + EntertainmentConfigurationAction::Start => { + ec.active_streamer = + Some(RType::AuthV1.link_to(uuid!(STANDARD_APPLICATION_ID))); + ec.status = EntertainmentConfigurationStatus::Active; + } + EntertainmentConfigurationAction::Stop => { + ec.active_streamer = None; + ec.status = EntertainmentConfigurationStatus::Inactive; + } + } + } + })?; + + drop(lock); + + V2Reply::ok(rlink) +} diff --git a/src/routes/clip/grouped_light.rs b/src/routes/clip/grouped_light.rs new file mode 100644 index 0000000..f5eea2f --- /dev/null +++ b/src/routes/clip/grouped_light.rs @@ -0,0 +1,19 @@ +use serde_json::Value; + +use bifrost_api::backend::BackendRequest; +use hue::api::{GroupedLight, GroupedLightUpdate, ResourceLink}; + +use crate::routes::clip::{ApiV2Result, V2Reply}; +use crate::server::appstate::AppState; + +pub async fn put_grouped_light(state: &AppState, rlink: ResourceLink, put: Value) -> ApiV2Result { + let upd: GroupedLightUpdate = serde_json::from_value(put)?; + + let lock = state.res.lock().await; + lock.get::(&rlink)?; + lock.backend_request(BackendRequest::GroupedLightUpdate(rlink, upd))?; + + drop(lock); + + V2Reply::ok(rlink) +} diff --git a/src/routes/clip/light.rs b/src/routes/clip/light.rs new file mode 100644 index 0000000..ef9e575 --- /dev/null +++ b/src/routes/clip/light.rs @@ -0,0 +1,21 @@ +use serde_json::Value; + +use bifrost_api::backend::BackendRequest; +use hue::api::{Light, LightUpdate, ResourceLink}; + +use crate::routes::clip::{ApiV2Result, V2Reply}; +use crate::server::appstate::AppState; + +pub async fn put_light(state: &AppState, rlink: ResourceLink, put: Value) -> ApiV2Result { + let lock = state.res.lock().await; + + let _ = lock.get::(&rlink)?; + + let upd: LightUpdate = serde_json::from_value(put)?; + + lock.backend_request(BackendRequest::LightUpdate(rlink, upd))?; + + drop(lock); + + V2Reply::ok(rlink) +} diff --git a/src/routes/clip/mod.rs b/src/routes/clip/mod.rs new file mode 100644 index 0000000..8cd6d4a --- /dev/null +++ b/src/routes/clip/mod.rs @@ -0,0 +1,281 @@ +pub mod device; +pub mod entertainment_configuration; +pub mod grouped_light; +pub mod light; +pub mod room; +pub mod scene; +pub mod zigbee_device_discovery; + +use bifrost_api::backend::BackendRequest; +use entertainment_configuration as ent_conf; + +use axum::Router; +use axum::extract::{Path, State}; +use axum::routing::{delete, get, post, put}; +use hue::api::{RType, ResourceLink}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::error::{ApiError, ApiResult}; +use crate::routes::extractor::Json; +use crate::server::appstate::AppState; + +#[derive(Debug, Serialize, Deserialize)] +pub struct V2Error { + pub description: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct V2Reply { + pub data: Vec, + pub errors: Vec, +} + +type ApiV2Result = ApiResult>>; + +impl V2Reply { + fn ok(obj: T) -> ApiV2Result { + Ok(Json(V2Reply { + data: vec![serde_json::to_value(obj)?], + errors: vec![], + })) + } + + fn list(data: Vec) -> ApiV2Result { + Ok(Json(V2Reply { + data: data + .into_iter() + .map(|e| serde_json::to_value(e)) + .collect::>()?, + errors: vec![], + })) + } +} + +async fn get_all_resources(State(state): State) -> ApiV2Result { + let lock = state.res.lock().await; + let res = lock.get_resources(); + drop(lock); + V2Reply::list(res) +} + +pub async fn get_resource(State(state): State, Path(rtype): Path) -> ApiV2Result { + let lock = state.res.lock().await; + let res = lock.get_resources_by_type(rtype); + drop(lock); + V2Reply::list(res) +} + +async fn post_resource( + State(state): State, + Path(rtype): Path, + Json(req): Json, +) -> ApiV2Result { + log::info!("POST {rtype:?}"); + log::debug!("Json data:\n{}", serde_json::to_string_pretty(&req)?); + + match rtype { + RType::EntertainmentConfiguration => ent_conf::post_resource(&state, req).await, + RType::Scene => scene::post_scene(&state, req).await, + + /* Not supported yet by Bifrost */ + RType::BehaviorInstance + | RType::GeofenceClient + | RType::Room + | RType::ServiceGroup + | RType::SmartScene + | RType::Zone => { + let err = ApiError::CreateNotYetSupported(rtype); + log::warn!("{err}"); + Err(err) + } + + /* Not allowed by protocol */ + RType::AuthV1 + | RType::BehaviorScript + | RType::Bridge + | RType::BridgeHome + | RType::Button + | RType::CameraMotion + | RType::Contact + | RType::Device + | RType::DevicePower + | RType::DeviceSoftwareUpdate + | RType::Entertainment + | RType::Geolocation + | RType::GroupedLight + | RType::GroupedLightLevel + | RType::GroupedMotion + | RType::Homekit + | RType::Light + | RType::LightLevel + | RType::Matter + | RType::MatterFabric + | RType::Motion + | RType::PrivateGroup + | RType::PublicImage + | RType::RelativeRotary + | RType::Taurus + | RType::Tamper + | RType::Temperature + | RType::ZgpConnectivity + | RType::ZigbeeConnectivity + | RType::ZigbeeDeviceDiscovery => { + let err = ApiError::CreateNotAllowed(rtype); + log::error!("{err}"); + Err(err) + } + } +} + +pub async fn get_resource_id( + State(state): State, + Path(rlink): Path, +) -> ApiV2Result { + V2Reply::ok(state.res.lock().await.get_resource(&rlink)?) +} + +async fn put_resource_id( + State(state): State, + Path(rlink): Path, + Json(put): Json, +) -> ApiV2Result { + log::info!("PUT {rlink:?}"); + log::debug!("Json data:\n{}", serde_json::to_string_pretty(&put)?); + + match rlink.rtype { + /* Allowed + supported */ + RType::Device => device::put_device(&state, rlink, put).await, + RType::EntertainmentConfiguration => ent_conf::put_resource_id(&state, rlink, put).await, + RType::GroupedLight => grouped_light::put_grouped_light(&state, rlink, put).await, + RType::Light => light::put_light(&state, rlink, put).await, + RType::Scene => scene::put_scene(&state, rlink, put).await, + RType::Room => room::put_room(&state, rlink, put).await, + RType::ZigbeeDeviceDiscovery => { + zigbee_device_discovery::put_zigbee_device_discovery(&state, rlink, put).await + } + + /* Allowed, but support is missing in Bifrost */ + RType::BehaviorInstance + | RType::Bridge + | RType::Button + | RType::CameraMotion + | RType::Contact + | RType::DevicePower + | RType::DeviceSoftwareUpdate + | RType::Entertainment + | RType::GeofenceClient + | RType::Geolocation + | RType::GroupedLightLevel + | RType::GroupedMotion + | RType::Homekit + | RType::LightLevel + | RType::Matter + | RType::Motion + | RType::RelativeRotary + | RType::ServiceGroup + | RType::SmartScene + | RType::Temperature + | RType::ZgpConnectivity + | RType::ZigbeeConnectivity + | RType::Zone => { + /* check that the resource exists, otherwise we should return 404 */ + state.res.lock().await.get_resource(&rlink)?; + + let err = ApiError::UpdateNotYetSupported(rlink.rtype); + log::warn!("{err}"); + Err(err) + } + + /* Not allowed by protocol */ + RType::AuthV1 + | RType::BehaviorScript + | RType::BridgeHome + | RType::MatterFabric + | RType::PrivateGroup + | RType::PublicImage + | RType::Taurus + | RType::Tamper => { + let err = ApiError::UpdateNotAllowed(rlink.rtype); + log::error!("{err}"); + Err(err) + } + } +} + +async fn delete_resource_id( + State(state): State, + Path(rlink): Path, +) -> ApiV2Result { + log::info!("DELETE {rlink:?}"); + + match rlink.rtype { + /* Allowed (send request to backend) */ + RType::BehaviorInstance + | RType::Device + | RType::EntertainmentConfiguration + | RType::GeofenceClient + | RType::MatterFabric + | RType::Room + | RType::Scene + | RType::ServiceGroup + | RType::SmartScene + | RType::Zone => { + let lock = state.res.lock().await; + + /* check that the resource exists, otherwise we should return 404 */ + lock.get_resource(&rlink)?; + + /* request deletion from backend */ + lock.backend_request(BackendRequest::Delete(rlink))?; + + drop(lock); + + V2Reply::ok(rlink) + } + + /* Not allowed by protocol */ + RType::AuthV1 + | RType::BehaviorScript + | RType::Bridge + | RType::BridgeHome + | RType::Button + | RType::CameraMotion + | RType::Contact + | RType::DevicePower + | RType::DeviceSoftwareUpdate + | RType::Entertainment + | RType::Geolocation + | RType::GroupedLight + | RType::GroupedLightLevel + | RType::GroupedMotion + | RType::Homekit + | RType::Light + | RType::LightLevel + | RType::Matter + | RType::Motion + | RType::PrivateGroup + | RType::PublicImage + | RType::RelativeRotary + | RType::Tamper + | RType::Taurus + | RType::Temperature + | RType::ZgpConnectivity + | RType::ZigbeeConnectivity + | RType::ZigbeeDeviceDiscovery => { + let err = ApiError::DeleteNotAllowed(rlink.rtype); + log::error!("{err}"); + Err(err) + } + } +} + +pub fn router() -> Router { + Router::new() + .route("/", get(get_all_resources)) + .route("/{rtype}", get(get_resource)) + .route("/{rtype}", post(post_resource)) + .route("/{rtype}/{rid}", get(get_resource_id)) + .route("/{rtype}/{rid}", put(put_resource_id)) + .route("/{rtype}/{rid}", delete(delete_resource_id)) +} diff --git a/src/routes/clip/room.rs b/src/routes/clip/room.rs new file mode 100644 index 0000000..fcaed01 --- /dev/null +++ b/src/routes/clip/room.rs @@ -0,0 +1,26 @@ +use serde_json::Value; + +use bifrost_api::backend::BackendRequest; +use hue::api::{ResourceLink, Room, RoomUpdate}; + +use crate::routes::clip::{ApiV2Result, V2Reply}; +use crate::server::appstate::AppState; + +pub async fn put_room(state: &AppState, rlink: ResourceLink, put: Value) -> ApiV2Result { + let mut lock = state.res.lock().await; + lock.get::(&rlink)?; + + let mut upd: RoomUpdate = serde_json::from_value(put)?; + + if let Some(metadata) = upd.metadata.take() { + lock.update(&rlink.rid, |room: &mut Room| { + room.metadata += &metadata; + })?; + } + + lock.backend_request(BackendRequest::RoomUpdate(rlink, upd))?; + + drop(lock); + + V2Reply::ok(rlink) +} diff --git a/src/routes/clip/scene.rs b/src/routes/clip/scene.rs new file mode 100644 index 0000000..2476c05 --- /dev/null +++ b/src/routes/clip/scene.rs @@ -0,0 +1,40 @@ +use serde_json::Value; + +use bifrost_api::backend::BackendRequest; +use hue::api::{RType, ResourceLink, Scene, SceneUpdate}; + +use crate::routes::clip::{ApiV2Result, V2Reply}; +use crate::server::appstate::AppState; + +pub async fn post_scene(state: &AppState, req: Value) -> ApiV2Result { + let scene: Scene = serde_json::from_value(req)?; + + let lock = state.res.lock().await; + + let sid = lock.get_next_scene_id(&scene.group)?; + + let link_scene = RType::Scene.deterministic((scene.group.rid, sid)); + + lock.backend_request(BackendRequest::SceneCreate(link_scene, sid, scene))?; + + drop(lock); + + V2Reply::ok(link_scene) +} + +pub async fn put_scene(state: &AppState, rlink: ResourceLink, put: Value) -> ApiV2Result { + let mut lock = state.res.lock().await; + + let upd: SceneUpdate = serde_json::from_value(put)?; + + if let Some(md) = &upd.metadata { + lock.update::(&rlink.rid, |scn| scn.metadata += md)?; + } + + let _scene = lock.get::(&rlink)?; + + lock.backend_request(BackendRequest::SceneUpdate(rlink, upd))?; + drop(lock); + + V2Reply::ok(rlink) +} diff --git a/src/routes/clip/zigbee_device_discovery.rs b/src/routes/clip/zigbee_device_discovery.rs new file mode 100644 index 0000000..df9bbc2 --- /dev/null +++ b/src/routes/clip/zigbee_device_discovery.rs @@ -0,0 +1,24 @@ +use serde_json::Value; + +use bifrost_api::backend::BackendRequest; +use hue::api::{ResourceLink, ZigbeeDeviceDiscovery, ZigbeeDeviceDiscoveryUpdate}; + +use crate::routes::clip::{ApiV2Result, V2Reply}; +use crate::server::appstate::AppState; + +pub async fn put_zigbee_device_discovery( + state: &AppState, + rlink: ResourceLink, + put: Value, +) -> ApiV2Result { + let lock = state.res.lock().await; + lock.get::(&rlink)?; + + let upd: ZigbeeDeviceDiscoveryUpdate = serde_json::from_value(put)?; + + lock.backend_request(BackendRequest::ZigbeeDeviceDiscovery(rlink, upd))?; + + drop(lock); + + V2Reply::ok(rlink) +} diff --git a/src/routes/eventstream.rs b/src/routes/eventstream.rs new file mode 100644 index 0000000..44041cd --- /dev/null +++ b/src/routes/eventstream.rs @@ -0,0 +1,53 @@ +use axum::Router; +use axum::extract::State; +use axum::http::{HeaderMap, HeaderValue}; +use axum::response::sse::{Event, Sse}; +use axum::routing::get; +use futures::StreamExt; +use futures::stream::{self, Stream}; +use tokio_stream::wrappers::BroadcastStream; + +use crate::error::ApiResult; +use crate::server::appstate::AppState; + +pub async fn get_clip_v2( + headers: HeaderMap, + State(state): State, +) -> Sse>> { + let hello = tokio_stream::iter([Ok(Event::default().comment("hi"))]); + let last_event_id = headers.get("last-event-id").map(HeaderValue::to_str); + + let channel = state.res.lock().await.hue_event_stream().subscribe(); + let stream = BroadcastStream::new(channel); + let events = match last_event_id { + Some(Ok(id)) => { + let previous_events = state + .res + .lock() + .await + .hue_event_stream() + .events_sent_after_id(id); + stream::iter(previous_events.into_iter().map(Ok)) + .chain(stream) + .boxed() + } + _ => stream.boxed(), + }; + + let stream = events.map(move |e| { + let evt = e?; + let evt_id = evt.id(); + let json = [evt.block]; + log::trace!( + "## EVENT ##: {}", + serde_json::to_string(&json).unwrap_or_else(|_| "ERROR".to_string()) + ); + Ok(Event::default().id(evt_id).json_data(json)?) + }); + + Sse::new(hello.chain(stream)) +} + +pub fn router() -> Router { + Router::new().route("/clip/v2", get(get_clip_v2)) +} diff --git a/src/routes/extractor.rs b/src/routes/extractor.rs new file mode 100644 index 0000000..881c379 --- /dev/null +++ b/src/routes/extractor.rs @@ -0,0 +1,32 @@ +use axum::extract::rejection::JsonRejection; +use axum::extract::{FromRequest, Request}; +use axum::response::IntoResponse; +use bytes::Bytes; +use serde::Serialize; +use serde::de::DeserializeOwned; + +// Simple wrapper around axum::Json, which skips the header requirements. +// +// The axum version requires "Content-Type: application/json", which many +// (buggy) apps don't actually send. So we are forced to skip this check. +pub struct Json(pub T); + +impl FromRequest for Json +where + axum::Json: FromRequest, + T: DeserializeOwned, + S: Send + Sync, +{ + type Rejection = JsonRejection; + + async fn from_request(req: Request, state: &S) -> Result { + let bytes = Bytes::from_request(req, state).await?; + Ok(Self(axum::Json::from_bytes(&bytes)?.0)) + } +} + +impl IntoResponse for Json { + fn into_response(self) -> axum::response::Response { + axum::Json(self.0).into_response() + } +} diff --git a/src/routes/licenses.rs b/src/routes/licenses.rs new file mode 100644 index 0000000..7026142 --- /dev/null +++ b/src/routes/licenses.rs @@ -0,0 +1,63 @@ +use axum::Router; +use axum::response::IntoResponse; +use axum::routing::get; +use itertools::Itertools; +use serde_json::{Value, json}; + +use crate::routes::extractor::Json; +use crate::server::appstate::AppState; + +async fn packages() -> Json { + Json(json!([])) +} + +async fn hardcoded() -> Json { + Json(json!([{ + "Attributions": [], + "Package": "bifrost", + "SPDX-License-Identifiers": [ + "GPL-3.0" + ], + "SourceLinks": [ + "https://github.com/chrivers/bifrost", + ], + "Version": "0.9", + "Website": "https://github.com/chrivers/bifrost", + "licenses": { + "GPL-3.0": "gpl-3.0.txt", + } + }])) +} + +async fn license() -> impl IntoResponse { + const LICENSE: &str = include_str!("../../LICENSE"); + + let split = LICENSE + .find("Preamble") + .expect("License file must have preamble"); + + /* a bit of string trickery to make license render nicely in hue app */ + format!( + "{}{}", + &LICENSE[..split] + .split("\n\n ") + .map(|s| s.replace("\n ", " ")) + .join("\n\n"), + &LICENSE[split..] + .split("\n\n ") + .map(|s| s.replace("\n ", "\n").replace('\n', " ")) + .join("\n\n") + ) +} + +async fn rust_packages() -> Json { + Json(json!([])) +} + +pub fn router() -> Router { + Router::new() + .route("/packages.json", get(packages)) + .route("/hardcoded.json", get(hardcoded)) + .route("/rust-packages.json", get(rust_packages)) + .route("/gpl-3.0.txt", get(license)) +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs new file mode 100644 index 0000000..e347077 --- /dev/null +++ b/src/routes/mod.rs @@ -0,0 +1,174 @@ +use axum::Router; +use axum::extract::DefaultBodyLimit; +use axum::response::{IntoResponse, Response}; +use hue::error::{HueApiV1Error, HueError}; +use hue::legacy_api::ApiResourceType; +use hyper::StatusCode; +use serde_json::{Value, json}; +use thiserror::Error; + +use crate::error::ApiError; +use crate::routes::clip::{V2Error, V2Reply}; +use crate::routes::extractor::Json; +use crate::server::appstate::AppState; + +pub mod api; +pub mod auth; +pub mod bifrost; +pub mod clip; +pub mod eventstream; +pub mod extractor; +pub mod licenses; +pub mod updater; +pub mod upnp; + +#[derive(Error, Debug)] +pub enum ApiV1Error { + #[error(transparent)] + ApiError(#[from] ApiError), + + #[error(transparent)] + HueError(#[from] HueError), + + #[error(transparent)] + SerdeJsonError(#[from] serde_json::Error), + + #[error(transparent)] + HueApiV1(#[from] HueApiV1Error), + + #[error("Cannot create resources of type: {0:?}")] + V1CreateUnsupported(ApiResourceType), +} + +impl ApiV1Error { + pub const fn http_status_code(&self) -> StatusCode { + // Hue bridge seems to return 200 OK in almost all cases, and use the + // .error field to indicate the error. + match self { + Self::ApiError(_) + | Self::HueError(_) + | Self::SerdeJsonError(_) + | Self::V1CreateUnsupported(_) + | Self::HueApiV1( + HueApiV1Error::UnauthorizedUser + | HueApiV1Error::BodyContainsInvalidJson + | HueApiV1Error::ResourceNotfound + | HueApiV1Error::MethodNotAvailableForResource + | HueApiV1Error::MissingParametersInBody + | HueApiV1Error::ParameterNotAvailable + | HueApiV1Error::InvalidValueForParameter + | HueApiV1Error::ParameterNotModifiable + | HueApiV1Error::TooManyItemsInList + | HueApiV1Error::PortalConnectionIsRequired, + ) => StatusCode::OK, + + Self::HueApiV1(HueApiV1Error::BridgeInternalError) => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + pub const fn hue_error_code(&self) -> u32 { + match self { + Self::HueError(HueError::V1NotFound(_) | HueError::WrongType(_, _)) => { + HueApiV1Error::ResourceNotfound.error_code() + } + Self::HueApiV1(err) => err.error_code(), + Self::ApiError(_) | Self::HueError(_) | Self::SerdeJsonError(_) => { + HueApiV1Error::BridgeInternalError.error_code() + } + Self::V1CreateUnsupported(_) => { + HueApiV1Error::MethodNotAvailableForResource.error_code() + } + } + } +} + +type ApiV1Result = Result; + +impl IntoResponse for ApiV1Error { + fn into_response(self) -> Response { + let error_msg = format!("{self}"); + log::error!("V1 request failed: {error_msg}"); + + let res = Json(json!([ + { + "error": { + "type": self.hue_error_code(), + "address": "/", + "description": format!("{self}"), + } + } + ])); + + let status = self.http_status_code(); + + (status, res).into_response() + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let error_msg = format!("{self}"); + log::error!("Request failed: {error_msg}"); + + let res = Json(V2Reply:: { + data: vec![], + errors: vec![V2Error { + description: error_msg, + }], + }); + + let status = match self { + Self::HueError(err) => match err { + HueError::FromUtf8Error(_) + | HueError::SerdeJson(_) + | HueError::TryFromIntError(_) + | HueError::FromHexError(_) + | HueError::PackedStructError(_) + | HueError::UuidError(_) + | HueError::HueEntertainmentBadHeader + | HueError::EffectDurationOutOfRange(_) + | HueError::HueZigbeeUnknownFlags(_) => StatusCode::BAD_REQUEST, + + HueError::NotFound(_) | HueError::V1NotFound(_) | HueError::WrongType(_, _) => { + StatusCode::NOT_FOUND + } + + HueError::Full(_) => StatusCode::INSUFFICIENT_STORAGE, + + HueError::IOError(_) + | HueError::HueZigbeeDecodeError + | HueError::HueZigbeeEncodeError + | HueError::Undiffable + | HueError::Unmergable => StatusCode::INTERNAL_SERVER_ERROR, + }, + + Self::AuxNotFound(_) => StatusCode::NOT_FOUND, + + Self::CreateNotAllowed(_) | Self::UpdateNotAllowed(_) | Self::DeleteNotAllowed(_) => { + StatusCode::METHOD_NOT_ALLOWED + } + + Self::CreateNotYetSupported(_) + | Self::UpdateNotYetSupported(_) + | Self::DeleteNotYetSupported(_) => StatusCode::FORBIDDEN, + + _ => StatusCode::INTERNAL_SERVER_ERROR, + }; + + (status, res).into_response() + } +} + +pub fn router(appstate: AppState) -> Router<()> { + Router::new() + .nest("/api", api::router()) + .nest("/auth", auth::router()) + .nest("/updater", updater::router()) + .nest("/licenses", licenses::router()) + .nest("/description.xml", upnp::router()) + .nest("/clip/v2/resource", clip::router()) + .nest("/eventstream", eventstream::router()) + .nest("/bifrost", bifrost::router()) + .with_state(appstate) + .layer(DefaultBodyLimit::max(100 * 1024 * 1024)) +} diff --git a/src/routes/updater.rs b/src/routes/updater.rs new file mode 100644 index 0000000..93aef44 --- /dev/null +++ b/src/routes/updater.rs @@ -0,0 +1,45 @@ +use axum::Router; +use axum::extract::{Multipart, State}; +use axum::response::IntoResponse; +use axum::routing::post; +use hyper::HeaderMap; +use hyper::header::CONTENT_TYPE; + +use crate::error::ApiResult; +use crate::server::appstate::AppState; + +async fn post_updater( + State(state): State, + mut multipart: Multipart, +) -> ApiResult { + while let Some(field) = multipart.next_field().await.unwrap() { + let name = field.name().unwrap_or_default().to_string(); + let data = field.bytes().await.unwrap(); + + log::info!("Length of `{}` is {} bytes", name, data.len()); + } + + // Reset version updater cache, and fetch newest version + let version = { + let updater = state.updater(); + let mut lock = updater.lock().await; + lock.reset_cache(); + lock.get().await.clone() + }; + + // Patch bridge state with newest software version + let mut lock = state.res.lock().await; + lock.update_bridge_version(version); + drop(lock); + + // This is the exact output needed to trick the hue app into thinking we're + // a real hue bridge. After waiting a while, the app will check the bridge + // again, and happily conclude that the firmware has been upgraded. + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, "text/html".parse().unwrap()); + Ok((headers, "Upload OK, system will reboot")) +} + +pub fn router() -> Router { + Router::new().route("/", post(post_updater)) +} diff --git a/src/routes/upnp.rs b/src/routes/upnp.rs new file mode 100644 index 0000000..f76f4e2 --- /dev/null +++ b/src/routes/upnp.rs @@ -0,0 +1,43 @@ +use axum::Router; +use axum::extract::State; +use axum::response::IntoResponse; +use axum::routing::get; +use hyper::HeaderMap; +use hyper::header::CONTENT_TYPE; +use url::Url; +use uuid::Uuid; + +use crate::error::ApiResult; +use crate::model::upnp; +use crate::server::appstate::AppState; + +async fn description_xml(State(state): State) -> ApiResult { + let mac = state.api_short_config().await.mac; + let config = &state.config().bridge; + let ip = config.ipaddress; + let port = config.http_port; + + let url_base = Url::parse(&format!("http://{ip}:{port}/"))?; + let friendly_name = format!("Bifrost {ip}"); + let manufacturer = "Christian Iversen"; + let model_name = "Bifrost Bridge"; + let udn = Uuid::new_v5(&Uuid::NAMESPACE_OID, &mac.bytes()); + + let device = upnp::Device::new(friendly_name, manufacturer, model_name, udn) + .with_manufacturer_url(Url::parse("http://github.com/chrivers/bifrost")?) + .with_model_description("Bifrost Hue Bridge Emulator") + .with_model_number(hue::HUE_BRIDGE_V2_MODEL_ID) + .with_model_url(Url::parse("http://www.philips-hue.com")?) + .with_serial_number(hex::encode(mac.bytes())) + .with_presentation_url("index.html"); + + let root = upnp::Root::new(url_base, device); + + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, "text/xml".parse().unwrap()); + Ok((headers, upnp::to_xml(&root)?)) +} + +pub fn router() -> Router { + Router::new().route("/", get(description_xml)) +} diff --git a/src/server/appstate.rs b/src/server/appstate.rs new file mode 100644 index 0000000..db42b87 --- /dev/null +++ b/src/server/appstate.rs @@ -0,0 +1,124 @@ +use std::collections::HashMap; +use std::fs::{self, File}; +use std::sync::Arc; + +use camino::Utf8Path; +use chrono::Utc; +use tokio::sync::Mutex; + +use hue::legacy_api::{ApiConfig, ApiShortConfig, Whitelist}; +use svc::manager::SvmClient; + +use crate::config::AppConfig; +use crate::error::ApiResult; +use crate::model::state::{State, StateVersion}; +use crate::resource::Resources; +use crate::server::certificate; +use crate::server::updater::VersionUpdater; + +#[derive(Clone)] +pub struct AppState { + conf: Arc, + upd: Arc>, + svm: SvmClient, + pub res: Arc>, +} + +impl AppState { + pub async fn from_config(config: AppConfig, svm: SvmClient) -> ApiResult { + let certfile = &config.bifrost.cert_file; + + let certpath = Utf8Path::new(certfile); + if certpath.is_file() { + certificate::check_certificate(certpath, config.bridge.mac)?; + } else { + log::warn!("Missing certificate file [{certfile}], generating.."); + certificate::generate_and_save(certpath, config.bridge.mac)?; + } + + let mut res; + let upd = Arc::new(Mutex::new(VersionUpdater::with_default_version())); + let swversion = upd.lock().await.get().await.clone(); + + if let Ok(fd) = File::open(&config.bifrost.state_file) { + log::debug!("Existing state file found, loading.."); + let yaml = serde_yml::from_reader(fd)?; + let state = match State::version(&yaml)? { + StateVersion::V0 => { + log::info!("Detected state file version 0. Upgrading to new version.."); + let backup_path = &config.bifrost.state_file.with_extension("v0.bak"); + fs::rename(&config.bifrost.state_file, backup_path)?; + log::info!(" ..saved old state file as {backup_path}"); + State::from_v0(yaml)? + } + StateVersion::V1 => { + log::info!("Detected state file version 1. Loading.."); + State::from_v1(yaml)? + } + }; + res = Resources::new(swversion, state); + } else { + log::debug!("No state file found, initializing.."); + res = Resources::new(swversion, State::new()); + res.init(&hue::bridge_id(config.bridge.mac))?; + } + + res.reset_all_streaming()?; + + let conf = Arc::new(config); + let res = Arc::new(Mutex::new(res)); + + Ok(Self { + conf, + upd, + svm, + res, + }) + } + + #[must_use] + pub fn config(&self) -> Arc { + self.conf.clone() + } + + #[must_use] + pub fn updater(&self) -> Arc> { + self.upd.clone() + } + + #[must_use] + pub fn manager(&self) -> SvmClient { + self.svm.clone() + } + + #[must_use] + pub async fn api_short_config(&self) -> ApiShortConfig { + let mac = self.conf.bridge.mac; + ApiShortConfig::from_mac_and_version(mac, self.upd.lock().await.get().await) + } + + pub async fn api_config(&self, username: String) -> ApiResult { + let tz = tzfile::Tz::named(&self.conf.bridge.timezone)?; + let localtime = Utc::now().with_timezone(&&tz).naive_local(); + + let res = ApiConfig { + short_config: self.api_short_config().await, + ipaddress: self.conf.bridge.ipaddress, + netmask: self.conf.bridge.netmask, + gateway: self.conf.bridge.gateway, + timezone: self.conf.bridge.timezone.clone(), + whitelist: HashMap::from([( + username, + Whitelist { + create_date: Utc::now(), + last_use_date: Utc::now(), + name: "User#foo".to_string(), + }, + )]), + localtime, + ..ApiConfig::default() + }; + + Ok(res) + } +} diff --git a/src/server/banner.rs b/src/server/banner.rs new file mode 100644 index 0000000..76630a4 --- /dev/null +++ b/src/server/banner.rs @@ -0,0 +1,85 @@ +use std::io::{IsTerminal, Write}; + +use termcolor::{Color, ColorSpec, StandardStream, WriteColor}; + +use crate::error::ApiResult; + +struct Rainbow { + stderr: StandardStream, +} + +impl Rainbow { + fn new() -> Self { + /* detect if color output is reasonable */ + let cc = if std::io::stdout().is_terminal() { + termcolor::ColorChoice::Auto + } else { + termcolor::ColorChoice::Never + }; + + Self { + stderr: StandardStream::stderr(cc), + } + } + + fn out(&mut self, line: &str) -> ApiResult<()> { + /* array of (width, color) pairs */ + let colors = [ + (15, Color::Rgb(0xDC, 0x00, 0x00)), + (6, Color::Rgb(0xFF, 0xA5, 0x00)), + (11, Color::Rgb(0xD2, 0xD2, 0x00)), + (10, Color::Rgb(0x00, 0xC0, 0x00)), + (9, Color::Rgb(0x00, 0x00, 0xFF)), + (8, Color::Rgb(0x70, 0x10, 0xB0)), + (99, Color::Rgb(0x80, 0x10, 0x80)), + ]; + + let cols = colors + .into_iter() + .flat_map(|(r, c)| itertools::repeat_n(c, r)); + + let mut last_color = None; + + for (c, col) in line.chars().zip(cols) { + let color = match c { + '░' | '=' => Some(col), + _ => None, + }; + + /* Only output color code if the color changed */ + if last_color != color { + last_color = color; + self.stderr.set_color(ColorSpec::new().set_fg(color))?; + } + + write!(self.stderr, "{c}")?; + } + + /* reset colors after printing */ + self.stderr.set_color(&ColorSpec::new())?; + + /* final newline */ + writeln!(self.stderr)?; + + Ok(()) + } +} + +pub fn print() -> ApiResult<()> { + let mut rainbow = Rainbow::new(); + + eprintln!(); + rainbow.out(r" ===================================================================")?; + rainbow.out(r" ███████████ ███ ██████ █████ ")?; + rainbow.out(r" ░░███░░░░░███ ░░░ ███░░███ ░░███ ")?; + rainbow.out(r" ░███ ░███ ████ ░███ ░░░ ████████ ██████ █████ ███████ ")?; + rainbow.out(r" ░██████████ ░░███ ███████ ░░███░░███ ███░░███ ███░░ ░░░███░ ")?; + rainbow.out(r" ░███░░░░░███ ░███ ░░░███░ ░███ ░░░ ░███ ░███░░█████ ░███ ")?; + rainbow.out(r" ░███ ░███ ░███ ░███ ░███ ░███ ░███ ░░░░███ ░███ ███")?; + rainbow.out(r" ███████████ █████ █████ █████ ░░██████ ██████ ░░█████ ")?; + rainbow.out(r" ░░░░░░░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░░ ░░░░░░ ░░░░░ ")?; + rainbow.out(r" ===================================================================")?; + eprintln!(); + + Ok(()) +} diff --git a/src/server/certificate.rs b/src/server/certificate.rs new file mode 100644 index 0000000..daf5c05 --- /dev/null +++ b/src/server/certificate.rs @@ -0,0 +1,174 @@ +use std::fs::File; +use std::io::{BufReader, Read, Write}; +use std::str::FromStr; + +use camino::Utf8Path; +use der::asn1::{GeneralizedTime, OctetString}; +use der::oid::db::rfc4519::COMMON_NAME; +use der::oid::db::rfc5280::ID_KP_SERVER_AUTH; +use der::pem::LineEnding; +use der::{DateTime, EncodePem}; +use mac_address::MacAddress; +use p256::ecdsa::DerSignature; +use p256::pkcs8::EncodePrivateKey; +use rsa::pkcs8::SubjectPublicKeyInfoRef; +use rsa::rand_core::OsRng; +use sha1::Sha1; +use sha2::Digest; +use x509_cert::Certificate; +use x509_cert::attr::AttributeTypeAndValue; +use x509_cert::builder::{Builder, CertificateBuilder, Profile}; +use x509_cert::certificate::CertificateInner; +use x509_cert::der::{Decode, Encode}; +use x509_cert::ext::pkix::{self, ExtendedKeyUsage, name::GeneralName}; +use x509_cert::name::Name; +use x509_cert::serial_number::SerialNumber; +use x509_cert::spki::SubjectPublicKeyInfoOwned; +use x509_cert::time::Validity; + +use crate::error::{ApiError, ApiResult}; + +/// Generate a self-signed X509 certificate, closely matching the type and style +/// used by a real Philips Hue bridge. +/// +/// Great care has been taken to match the real certificates as close as +/// possible. These certificates match on the following parameters: +/// +/// - Style of subject name +/// - Valid from/to +/// - Type of private key, signing key, and signature type +/// - Choice of X509v3 extensions, including the order of them +/// +/// Only these differences are known to remain: +/// +/// - This version generates "Extended Key Usage" *with* the "critical" +/// flag set, while the real certificates (unusually) don't. +/// This seems to be a benign difference, and would roughly double +/// the code size needed. +/// - Real hue bridge certificates are signed by a "root-bridge" cert, +/// acting as a kind of CA certificate for the instance certificate. +/// This also seems to have no negative impact. +/// +pub fn generate(secret_key: &p256::SecretKey, mac: MacAddress) -> ApiResult { + let public_key = secret_key.public_key(); + + let bridge_id = hue::bridge_id(mac); + + let subject = Name::from_str(&format!("CN={bridge_id},O=Philips Hue,C=NL"))?; + + /* self-signed certificate, so subject == issuer */ + let issuer = subject.clone(); + + let serial_number = SerialNumber::new(&hue::bridge_id_raw(mac))?; + + /* Philips Hue seems to start their certificates at the beginning of 2017.. */ + let not_before = GeneralizedTime::from_date_time(DateTime::new(2017, 1, 1, 0, 0, 0)?).into(); + + /* ..and end on the Y38K boundary (https://en.wikipedia.org/wiki/Year_2038_problem) */ + let not_after = GeneralizedTime::from_date_time(DateTime::new(2038, 1, 19, 3, 14, 7)?).into(); + + let validity = Validity { + not_before, + not_after, + }; + + /* Use "Manual" profile, since Hue certs have an unusual combination of X509 extensions */ + let profile = Profile::Manual { + issuer: Some(issuer.clone()), + }; + + let signer = ecdsa::SigningKey::::from(secret_key); + let pub_key = SubjectPublicKeyInfoOwned::from_key(public_key)?; + + /* Make certificate builder, which will allow us to build the final cert */ + let mut builder = CertificateBuilder::new( + profile, + serial_number.clone(), + validity, + subject, + pub_key.clone(), + &signer, + )?; + + /* Basic constraints extension */ + builder.add_extension(&pkix::BasicConstraints { + ca: false, + path_len_constraint: None, + })?; + + /* Key Usage extension */ + builder.add_extension(&pkix::KeyUsage(pkix::KeyUsages::DigitalSignature.into()))?; + + let der = pub_key.to_der()?; + let spki = SubjectPublicKeyInfoRef::from_der(&der)?; + + /* Extended Key Usage extension */ + builder.add_extension(&ExtendedKeyUsage(vec![ID_KP_SERVER_AUTH]))?; + + /* Subject Key Identifier extension */ + builder.add_extension(&pkix::SubjectKeyIdentifier::try_from(spki.clone())?)?; + + /* Authority Key Identifier extension */ + let mut aki = pkix::AuthorityKeyIdentifier::try_from(spki.clone())?; + aki.key_identifier = Some(OctetString::new( + Sha1::digest(spki.subject_public_key.raw_bytes()).as_slice(), + )?); + aki.authority_cert_issuer = Some(vec![GeneralName::DirectoryName(issuer)]); + aki.authority_cert_serial_number = Some(serial_number); + builder.add_extension(&aki)?; + + /* Finally ready to build the certificate */ + Ok(builder.build::()?) +} + +pub fn extract_common_name(rdr: impl Read) -> ApiResult> { + let bufread = &mut BufReader::new(rdr); + + for chunk in rustls_pemfile::certs(bufread) { + let cert = Certificate::from_der(&chunk?)?; + + for name in cert.tbs_certificate.subject.0 { + if let [ + AttributeTypeAndValue { + oid: COMMON_NAME, + value, + }, + ] = name.0.as_slice() + { + return Ok(Some(String::from_utf8(value.value().to_vec())?)); + } + } + } + + Ok(None) +} + +pub fn generate_and_save(certpath: &Utf8Path, mac: MacAddress) -> ApiResult<()> { + let secret_key = p256::SecretKey::random(&mut OsRng); + let cert = generate(&secret_key, mac)?; + let mut fd = File::create(certpath)?; + fd.write_all(secret_key.to_pkcs8_pem(LineEnding::LF)?.as_bytes())?; + fd.write_all(cert.to_pem(LineEnding::LF)?.as_bytes())?; + Ok(()) +} + +pub fn check_certificate(certpath: &Utf8Path, mac: MacAddress) -> ApiResult<()> { + let cn = extract_common_name(File::open(certpath)?)?; + let id = hue::bridge_id(mac); + match cn { + Some(cn) => { + if cn == id { + log::debug!("Found existing certificate for bridge id [{id}]"); + } else { + log::warn!("Certificate found, but common name (CN) does not match!"); + log::warn!(" [{id}] (expected)"); + log::warn!(" [{cn}] {certpath}"); + return Ok(()); + } + } + None => { + return Err(ApiError::CertificateInvalid(certpath.to_owned())); + } + } + Ok(()) +} diff --git a/src/server/entertainment.rs b/src/server/entertainment.rs new file mode 100644 index 0000000..520fcde --- /dev/null +++ b/src/server/entertainment.rs @@ -0,0 +1,323 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::io::ErrorKind; +use std::net::{Ipv4Addr, SocketAddr}; +use std::os::fd::AsFd; +use std::pin::Pin; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use chrono::Utc; +use nix::sys::socket; +use nix::sys::socket::sockopt::RcvBuf; +use openssl::ssl::{Ssl, SslContext, SslMethod}; +use tokio::io::AsyncReadExt; +use tokio::net::UdpSocket; +use tokio::sync::Mutex; +use tokio::time::timeout; +use tokio_openssl::SslStream; +use udp_stream::{UdpListenBuilder, UdpListener, UdpStream}; +use uuid::Uuid; + +use bifrost_api::backend::BackendRequest; +use hue::api::{Device, EntertainmentConfiguration, Light, RType}; +use hue::error::HueError; +use hue::stream::{ + HueStreamLightsV1, HueStreamLightsV2, HueStreamPacket, HueStreamPacketV1, HueStreamPacketV2, + Rgb16V2, Xy16V2, +}; +use svc::traits::Service; + +use crate::error::{ApiError, ApiResult}; +use crate::resource::Resources; +use crate::routes::auth::STANDARD_CLIENT_KEY; + +pub struct EntertainmentService { + addr: SocketAddr, + udp: Option>, + ctx: Option, + res: Arc>, +} + +impl EntertainmentService { + pub fn new(addr: Ipv4Addr, port: u16, res: Arc>) -> ApiResult { + let res = Self { + addr: SocketAddr::new(addr.into(), port), + udp: None, + ctx: None, + res, + }; + + Ok(res) + } + + async fn read_frame(sess: &mut SslStream, buf: &mut [u8]) -> ApiResult { + const TIMEOUT: Duration = Duration::from_secs(10); + + match timeout(TIMEOUT, sess.read(buf)).await { + Ok(Err(err)) if err.kind() == ErrorKind::UnexpectedEof => { + log::debug!("Sync stream stopped by sender"); + Ok(0) + } + Ok(Err(err)) => { + log::error!("Error while trying to read sync data: {err:?}"); + Err(ApiError::EntStreamDesync) + } + Err(_) => { + log::warn!("Timeout while waiting for sync data"); + Err(ApiError::EntStreamTimeout) + } + Ok(Ok(n)) => { + log::trace!("Read {n} bytes of sync data"); + Ok(n) + } + } + } + + #[allow(clippy::unused_async, unreachable_code, unused_variables)] + pub fn translate_v1_frame( + res: &Resources, + v1: HueStreamPacketV1, + ) -> ApiResult { + let mut lights = BTreeMap::new(); + for id_v1 in v1.light_ids() { + let uuid = res.from_id_v1(id_v1)?; + lights.insert(id_v1, uuid); + } + let light_ids: BTreeSet<_> = lights.values().copied().collect(); + + let mut best_included = 0; + let mut best_excluded = 0; + let mut best_area = None; + + for uuid in res.get_resource_ids_by_type(RType::EntertainmentConfiguration) { + let entconf = res.get_id::(uuid)?; + + let ent_light_ids: BTreeSet = + entconf.light_services.iter().map(|ls| ls.rid).collect(); + + let included = light_ids.intersection(&ent_light_ids).count(); + let excluded = light_ids.difference(&ent_light_ids).count(); + + if (included > best_included) + || ((included == best_included) && (excluded < best_excluded)) + { + log::trace!("Found candidate entertainment_configuration: {uuid}"); + log::trace!(" lights included: {included}"); + log::trace!(" lights excluded: {excluded}"); + best_included = included; + best_excluded = excluded; + best_area = Some(uuid); + } + } + + if let Some(area) = best_area { + let mut mapping = BTreeMap::new(); + for (id_v1, id) in &lights { + let light: &Light = res.get_id(*id)?; + let dev: &Device = res.get(&light.owner)?; + let ent_svc = dev.entertainment_service().ok_or(HueError::NotFound(*id))?; + + mapping.insert(*id_v1, ent_svc.rid); + } + + let entconf = res.get_id::(area)?; + + let lights = match v1.lights { + HueStreamLightsV1::Rgb(rgb16_v1s) => { + let mut v2 = vec![]; + + for rgb in rgb16_v1s { + let ent = mapping[&rgb.light_id]; + for chan in &entconf.channels { + for member in &chan.members { + if member.service.rid != ent { + continue; + } + + v2.push(Rgb16V2 { + channel: u8::try_from(chan.channel_id)?, + rgb: rgb.rgb, + }); + + // there's almost always just a single + // member for each channel, but we don't + // want to add the light multiple times + break; + } + } + } + + HueStreamLightsV2::Rgb(v2) + } + HueStreamLightsV1::Xy(xy16_v1s) => { + let mut v2 = vec![]; + + for xy in xy16_v1s { + let ent = mapping[&xy.light_id]; + for chan in &entconf.channels { + for member in &chan.members { + if member.service.rid != ent { + continue; + } + + v2.push(Xy16V2 { + channel: u8::try_from(chan.channel_id)?, + xy: xy.xy, + }); + + // there's almost always just a single + // member for each channel, but we don't + // want to add the light multiple times + break; + } + } + } + + HueStreamLightsV2::Xy(v2) + } + }; + + Ok(HueStreamPacketV2 { area, lights }) + } else { + Err(ApiError::EntStreamInitError) + } + } + + pub fn translate_frame(res: &Resources, pkt: HueStreamPacket) -> ApiResult { + match pkt { + HueStreamPacket::V1(v1) => Self::translate_v1_frame(res, v1), + HueStreamPacket::V2(v2) => Ok(v2), + } + } + + pub async fn run_loop(&self, mut sess: SslStream) -> ApiResult<()> { + let mut buf = [0u8; 1024]; + + timeout(Duration::from_secs(5), Pin::new(&mut sess).accept()) + .await + .map_err(|_| ApiError::EntStreamTimeout)??; + + // read the first frame, and use it to look up area, color mode, etc. + // this means we discard the first frame, but since we expect at least + // 10 frames *per second*, this is acceptable. + let mut sz = Self::read_frame(&mut sess, &mut buf).await?; + log::trace!("First entertainment frame: {}", hex::encode(&buf[..sz])); + let raw = HueStreamPacket::parse(&buf[..sz])?; + + let lock = self.res.lock().await; + + let header = Self::translate_frame(&lock, raw)?; + + // look up entertainment area, to make sure it exists + let _ent: &EntertainmentConfiguration = lock.get_id(header.area)?; + + // request entertainment mode start + lock.backend_request(BackendRequest::EntertainmentStart(header.area))?; + + drop(lock); + + let mut fps = 0; + let mut period = Utc::now().timestamp(); + + loop { + let view = &buf[..sz]; + log::trace!("Packet buffer: {}", view.escape_ascii()); + + let raw = HueStreamPacket::parse(view)?; + let pkt = Self::translate_frame(&*self.res.lock().await, raw)?; + + if pkt.color_mode() != header.color_mode() { + log::error!("Entertainment Mode color_mode changed mid-stream."); + return Err(ApiError::EntStreamDesync); + } + + if pkt.area != header.area { + log::error!("Entertainment Mode area changed mid-stream."); + return Err(ApiError::EntStreamDesync); + } + + let ts = Utc::now().timestamp(); + if period != ts { + log::info!("Incoming entertainment fps: {fps}"); + period = ts; + fps = 0; + } + + fps += 1; + let req = BackendRequest::EntertainmentFrame(pkt.lights); + self.res.lock().await.backend_request(req)?; + + sz = Self::read_frame(&mut sess, &mut buf).await?; + if sz == 0 { + break; + } + } + + Ok(()) + } +} + +#[async_trait] +impl Service for EntertainmentService { + type Error = ApiError; + + async fn configure(&mut self) -> Result<(), Self::Error> { + let mut bldr = SslContext::builder(SslMethod::dtls_server())?; + + bldr.set_psk_server_callback(|_sslref, cid, psk| { + let client_id = String::from_utf8_lossy(cid.unwrap_or_default()); + log::debug!("Setting PSK for {client_id}",); + STANDARD_CLIENT_KEY.write_to_slice(psk).unwrap(); + + log::trace!("psk: {}", hex::encode(&psk[..16])); + Ok(16) + }); + + self.ctx = Some(bldr.build()); + Ok(()) + } + + async fn start(&mut self) -> Result<(), Self::Error> { + let socket = UdpSocket::bind(self.addr).await?; + // We need a very small receive buffer, since we deliberately want to + // drop packets if we can't keep up with the sync mode packets + socket::setsockopt(&socket.as_fd(), RcvBuf, &512)?; + + let listener = UdpListenBuilder::new(socket) + .with_buffer_size(512) + .listen() + .await?; + self.udp = Some(Arc::new(listener)); + Ok(()) + } + + async fn run(&mut self) -> Result<(), Self::Error> { + let Some(udp) = self.udp.clone() else { + return Err(ApiError::service_error("Udp not initialized")); + }; + + let Some(ctx) = self.ctx.as_ref() else { + return Err(ApiError::service_error("Ctx not initialized")); + }; + + loop { + let (socket, _addr) = udp.accept().await?; + let ssl = Ssl::new(ctx)?; + let stream = SslStream::new(ssl, socket)?; + + match self.run_loop(stream).await { + Ok(()) => log::info!("Entertainment stream finished"), + Err(err) => log::error!("Entertainment stream error: {err}"), + } + + let req = BackendRequest::EntertainmentStop(); + self.res.lock().await.backend_request(req)?; + } + } + + async fn stop(&mut self) -> Result<(), Self::Error> { + self.udp.take(); + Ok(()) + } +} diff --git a/src/server/http.rs b/src/server/http.rs new file mode 100644 index 0000000..d39bc5b --- /dev/null +++ b/src/server/http.rs @@ -0,0 +1,148 @@ +use std::net::{Ipv4Addr, SocketAddr}; +use std::time::Duration; + +use async_trait::async_trait; +use axum::extract::Request; +use axum_server::accept::{Accept, DefaultAcceptor}; +use axum_server::service::{MakeService, SendService}; +use axum_server::tls_openssl::{OpenSSLAcceptor, OpenSSLConfig}; +use axum_server::{Handle, Server}; +use camino::Utf8Path; +use futures::FutureExt; +use futures::future::BoxFuture; +use hyper::body::Incoming; +use tokio::io::{AsyncRead, AsyncWrite}; +use tokio::net::TcpStream; + +use svc::traits::{Service, StopResult}; + +use crate::error::{ApiError, ApiResult}; + +pub struct HttpServer { + addr: SocketAddr, + bind: fn(&Self) -> ApiResult>, + server: Option, + svc: S, + extra: E, + handle: Handle, +} + +#[async_trait] +impl Service for HttpServer>, E> +where + E: Send + Unpin, + S: Send + Clone + MakeService> + 'static, + S::MakeFuture: Send, + A: Accept + Clone + Send + Sync + 'static, + A::Stream: AsyncRead + AsyncWrite + Unpin + Send, + A::Service: SendService> + Send, + A::Future: Send, +{ + type Error = ApiError; + + async fn start(&mut self) -> Result<(), ApiError> { + log::info!("Opening listen port on {}", self.addr); + self.server = Some( + (self.bind)(self)? + .handle(self.handle.clone()) + .serve(self.svc.clone()) + .boxed(), + ); + Ok(()) + } + + async fn run(&mut self) -> Result<(), ApiError> { + if let Some(server) = self.server.take() { + server.await?; + } + Ok(()) + } + + async fn stop(&mut self) -> Result<(), ApiError> { + log::info!("Stopping server {}", self.addr); + self.server.take(); + self.handle = Handle::new(); + Ok(()) + } + + async fn signal_stop(&mut self) -> Result { + self.handle.graceful_shutdown(Some(Duration::from_secs(1))); + Ok(StopResult::Delivered) + } +} + +impl HttpServer +where + Self: Service, +{ + pub fn http(listen_addr: Ipv4Addr, listen_port: u16, svc: S) -> Self + where + S: Send + Clone + MakeService>, + S::MakeFuture: Send, + { + let addr = SocketAddr::from((listen_addr, listen_port)); + + Self { + addr, + bind: |slf| Ok(axum_server::bind(slf.addr)), + server: None, + svc, + extra: (), + handle: Handle::new(), + } + } +} + +impl HttpServer +where + Server: Send, + Self: Service, + S: Send + Unpin, +{ + pub fn https_openssl( + listen_addr: Ipv4Addr, + listen_port: u16, + svc: S, + certfile: &Utf8Path, + ) -> ApiResult { + use std::sync::Arc; + + use axum_server::tls_openssl::OpenSSLConfig; + use openssl::ssl::{AlpnError, SslAcceptor, SslFiletype, SslMethod, SslRef}; + + fn alpn_select<'a>(_tls: &mut SslRef, client: &'a [u8]) -> Result<&'a [u8], AlpnError> { + openssl::ssl::select_next_proto(b"\x02h2\x08http/1.1", client).ok_or(AlpnError::NOACK) + } + + // the default axum-server function for configuring openssl uses + // [`SslAcceptor::mozilla_modern_v5`], which requires TLSv1.3. + // + // That protocol version is too new for some important clients, like + // Hue Sync for PC, so manually construct an OpenSSLConfig here, with + // slightly more relaxed settings. + + log::debug!("Loading certificate from [{certfile}]"); + + let mut tls_builder = SslAcceptor::mozilla_intermediate_v5(SslMethod::tls())?; + tls_builder.set_certificate_file(certfile, SslFiletype::PEM)?; + tls_builder.set_private_key_file(certfile, SslFiletype::PEM)?; + tls_builder.check_private_key()?; + tls_builder.set_alpn_select_callback(alpn_select); + let acceptor = tls_builder.build(); + + let config = OpenSSLConfig::from_acceptor(Arc::new(acceptor)); + + let addr = SocketAddr::from((listen_addr, listen_port)); + + let srv = Self { + addr, + bind: |slf: &Self| Ok(axum_server::bind_openssl(slf.addr, slf.extra.clone())), + server: None, + svc, + extra: config, + handle: Handle::new(), + }; + + Ok(srv) + } +} diff --git a/src/server/hueevents.rs b/src/server/hueevents.rs new file mode 100644 index 0000000..0467a30 --- /dev/null +++ b/src/server/hueevents.rs @@ -0,0 +1,88 @@ +use std::collections::VecDeque; + +use chrono::{DateTime, Utc}; +use tokio::sync::broadcast::{Receiver, Sender}; + +use hue::event::EventBlock; + +#[derive(Clone, Debug)] +pub struct HueEventRecord { + timestamp: DateTime, + index: u32, + pub block: EventBlock, +} + +impl HueEventRecord { + #[must_use] + pub fn id(&self) -> String { + format!("{}:{}", self.timestamp.timestamp(), self.index) + } +} + +#[derive(Clone, Debug)] +pub struct HueEventStream { + timestamp: DateTime, + index: u32, + hue_updates: Sender, + buffer: VecDeque, +} + +impl HueEventStream { + #[must_use] + pub fn new(buffer_capacity: usize) -> Self { + Self { + timestamp: Utc::now(), + index: 0, + hue_updates: Sender::new(32), + buffer: VecDeque::with_capacity(buffer_capacity), + } + } + + fn add_to_buffer(&mut self, record: HueEventRecord) { + if self.buffer.len() == self.buffer.capacity() { + self.buffer.pop_front(); + self.buffer.push_back(record); + debug_assert!(self.buffer.len() == self.buffer.capacity()); + } else { + self.buffer.push_back(record); + } + } + + fn generate_record(&mut self, block: EventBlock) -> HueEventRecord { + let timestamp = Utc::now(); + if timestamp.timestamp() == self.timestamp.timestamp() { + self.index += 1; + } else { + self.index = 0; + self.timestamp = timestamp; + } + HueEventRecord { + block, + timestamp, + index: self.index, + } + } + + #[must_use] + pub fn events_sent_after_id(&self, id: &str) -> Vec { + let mut events = self.buffer.iter().skip_while(|record| record.id() != id); + match events.next() { + Some(_) => events.cloned().collect(), + // return all events if requested event is not in buffer + None => self.buffer.iter().cloned().collect(), + } + } + + pub fn hue_event(&mut self, block: EventBlock) { + let record = self.generate_record(block); + self.add_to_buffer(record.clone()); + if let Err(err) = self.hue_updates.send(record) { + log::trace!("Overflow on hue event pipe: {err}"); + } + } + + #[must_use] + pub fn subscribe(&self) -> Receiver { + self.hue_updates.subscribe() + } +} diff --git a/src/server/mdns.rs b/src/server/mdns.rs new file mode 100644 index 0000000..5e3d1dc --- /dev/null +++ b/src/server/mdns.rs @@ -0,0 +1,117 @@ +use std::net::{IpAddr, Ipv4Addr}; + +use async_trait::async_trait; +use mac_address::MacAddress; +use mdns_sd::{ServiceDaemon, ServiceInfo}; + +use svc::traits::{Service, StopResult}; +use tokio::sync::watch::{self, Receiver, Sender}; + +use crate::error::ApiError; + +pub struct MdnsService { + mac: MacAddress, + ip: Ipv4Addr, + daemon: Option, + shutdown: Option>, + signal: Option>, +} + +impl MdnsService { + #[must_use] + pub const fn new(mac: MacAddress, ip: Ipv4Addr) -> Self { + Self { + mac, + ip, + daemon: None, + shutdown: None, + signal: None, + } + } +} + +#[async_trait] +impl Service for MdnsService { + type Error = ApiError; + + async fn configure(&mut self) -> Result<(), Self::Error> { + Ok(()) + } + + async fn start(&mut self) -> Result<(), Self::Error> { + let mdns = ServiceDaemon::new()?; + mdns.enable_interface(IpAddr::from(self.ip))?; + let service_type = "_hue._tcp.local."; + let instance_name = format!("bifrost-{}", hex::encode(&self.mac.bytes()[3..])); + let service_hostname = format!("{instance_name}.local."); + let service_addr = self.ip.to_string(); + let service_port = 443; + + let bridge_id = hue::bridge_id(self.mac); + + let properties = [ + ("bridgeid", bridge_id.as_str()), + ("modelid", hue::HUE_BRIDGE_V2_MODEL_ID), + ]; + + let service_info = ServiceInfo::new( + service_type, + &instance_name, + &service_hostname, + service_addr, + service_port, + &properties[..], + )?; + + mdns.register(service_info)?; + + let (tx, rx) = watch::channel(false); + self.shutdown = Some(rx); + self.signal = Some(tx); + self.daemon = Some(mdns); + + log::info!( + "Registered service {}.{} as {}", + &instance_name, + &service_type, + &service_hostname + ); + + Ok(()) + } + + async fn run(&mut self) -> Result<(), Self::Error> { + if let Some(shutdown) = &mut self.shutdown { + // wait for shutdown signal + while !*shutdown.borrow() { + shutdown.changed().await.map_err(ApiError::service_error)?; + } + + // remove daemon handle, and request shutdown + if let Some(daemon) = self.daemon.take() { + daemon + .shutdown()? + .recv_async() + .await + .map_err(ApiError::service_error)?; + } + } + + Ok(()) + } + + async fn stop(&mut self) -> Result<(), Self::Error> { + Ok(()) + } + + async fn signal_stop(&mut self) -> Result { + if let Some(signal) = self.signal.take() { + log::debug!("Shutting down mdns.."); + signal + .send(true) + .map_err(|_| ApiError::service_error("Failed to send stop signal"))?; + } + + Ok(StopResult::Delivered) + } +} diff --git a/src/server/mod.rs b/src/server/mod.rs new file mode 100644 index 0000000..8e3ab24 --- /dev/null +++ b/src/server/mod.rs @@ -0,0 +1,165 @@ +#[cfg(feature = "server-banner")] +pub mod banner; + +pub mod appstate; +pub mod certificate; +pub mod entertainment; +pub mod http; +pub mod hueevents; +pub mod mdns; +pub mod ssdp; +pub mod updater; + +use std::fs::File; +use std::io::Write; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::sync::Arc; +use std::time::Duration; + +use axum::body::Body; +use axum::extract::connect_info::IntoMakeServiceWithConnectInfo; +use axum::extract::{ConnectInfo, Request}; +use axum::response::Response; +use axum::{Router, ServiceExt}; + +use camino::Utf8PathBuf; +use tokio::select; +use tokio::sync::Mutex; +use tokio::time::{MissedTickBehavior, sleep_until}; +use tower::Layer; +use tower_http::cors::{AllowOrigin, Any, CorsLayer}; +use tower_http::normalize_path::{NormalizePath, NormalizePathLayer}; +use tower_http::trace::TraceLayer; +use tracing::{Span, info_span}; + +use crate::error::ApiResult; +use crate::resource::Resources; +use crate::routes; +use crate::server::appstate::AppState; +use crate::server::updater::VersionUpdater; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Protocol { + Http, + Https, +} + +fn trace_layer_make_span_with(request: &Request, protocol: Protocol) -> Span { + let addr = request + .extensions() + .get::>() + .map_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED), |ci| ci.0.ip()); + + match protocol { + Protocol::Https => info_span!( + "https", + client = ?addr, + method = ?request.method(), + uri = ?request.uri(), + status = tracing::field::Empty, + ), + Protocol::Http => info_span!( + "http", + client = ?addr, + method = ?request.method(), + uri = ?request.uri(), + status = tracing::field::Empty, + ), + } +} + +fn trace_layer_on_response(response: &Response, latency: Duration, span: &Span) { + span.record( + "latency", + tracing::field::display(format!("{}μs", latency.as_micros())), + ); + span.record("status", tracing::field::display(response.status())); +} + +fn router(protocol: Protocol, appstate: AppState) -> Router<()> { + routes::router(appstate).layer( + TraceLayer::new_for_http() + .make_span_with(move |request: &Request| trace_layer_make_span_with(request, protocol)) + .on_response(trace_layer_on_response), + ) +} + +#[must_use] +pub fn build_service( + protocol: Protocol, + appstate: AppState, +) -> IntoMakeServiceWithConnectInfo, SocketAddr> { + let cors_layer = CorsLayer::new() + .allow_methods(Any) + .allow_origin(AllowOrigin::any()) + .allow_headers(Any); + let normalized = NormalizePathLayer::trim_trailing_slash() + .layer(router(protocol, appstate).layer(cors_layer)); + + ServiceExt::::into_make_service_with_connect_info(normalized) +} + +pub async fn config_writer(res: Arc>, filename: Utf8PathBuf) -> ApiResult<()> { + const STABILIZE_TIME: Duration = Duration::from_secs(1); + + let rx = res.lock().await.state_channel(); + let tmp = filename.with_extension("tmp"); + + let mut old_state = res.lock().await.serialize()?; + + loop { + /* Wait for change notification */ + rx.notified().await; + + /* Updates often happen in burst, and we don't want to write the state + * file over and over, so ignore repeated update notifications within + * STABILIZE_TIME */ + let deadline = tokio::time::Instant::now() + STABILIZE_TIME; + loop { + select! { + () = rx.notified() => {}, + () = sleep_until(deadline) => break, + } + } + + /* Now that the state is likely stabilized, serialize the new state */ + let new_state = res.lock().await.serialize()?; + + /* If state is not actually changed, try again */ + if old_state == new_state && filename.exists() { + continue; + } + + log::debug!("Config changed, saving.."); + + let mut fd = File::create(&tmp)?; + fd.write_all(new_state.as_bytes())?; + std::fs::rename(&tmp, &filename)?; + + old_state = new_state; + } +} + +#[allow(clippy::significant_drop_tightening)] +pub async fn version_updater( + res: Arc>, + upd: Arc>, +) -> ApiResult<()> { + const INTERVAL: Duration = Duration::from_secs(60); + let mut interval = tokio::time::interval(INTERVAL); + interval.set_missed_tick_behavior(MissedTickBehavior::Skip); + + let mut version = upd.lock().await.get().await.clone(); + + loop { + interval.tick().await; + + let mut lock = upd.lock().await; + let new_version = lock.get().await; + if new_version != &version { + log::info!("New version detected! Patching state database with new version numbers.."); + version.clone_from(new_version); + res.lock().await.update_bridge_version(version.clone()); + } + } +} diff --git a/src/server/ssdp.rs b/src/server/ssdp.rs new file mode 100644 index 0000000..dc4add5 --- /dev/null +++ b/src/server/ssdp.rs @@ -0,0 +1,148 @@ +use std::net::Ipv4Addr; +use std::sync::Arc; + +use async_trait::async_trait; +use mac_address::MacAddress; +use tokio::sync::Mutex; +use tokio::sync::watch::{self, Receiver, Sender}; +use tokio_ssdp::{Device, Server}; + +use svc::traits::{Service, StopResult}; +use uuid::Uuid; + +use crate::error::ApiError; +use crate::server::updater::VersionUpdater; + +pub struct SsdpService { + service: Option, + updater: Arc>, + usn: Uuid, + mac: MacAddress, + ip: Ipv4Addr, + signal: Option>, + shutdown: Option>, +} + +/// Prefix used in all USN for hue bridges +/// +/// The USN is constructed like so: +/// +/// {PREFIX}-{MAC} +/// +/// Example: 2f402f80-da50-11e1-9b23-112233445566 +const HUE_BRIDGE_USN_PREFIX: &str = "2f402f80-da50-11e1-9b23"; + +#[must_use] +pub fn hue_bridge_usn(mac: MacAddress) -> Uuid { + let hexmac = hex::encode(mac.bytes()); + let uuid_str = format!("{HUE_BRIDGE_USN_PREFIX}-{hexmac}"); + Uuid::try_parse(&uuid_str).unwrap() +} + +impl SsdpService { + #[must_use] + pub fn new(mac: MacAddress, ip: Ipv4Addr, updater: Arc>) -> Self { + Self { + service: None, + updater, + mac, + ip, + usn: hue_bridge_usn(mac), + shutdown: None, + signal: None, + } + } +} + +#[async_trait] +impl Service for SsdpService { + type Error = ApiError; + + async fn configure(&mut self) -> Result<(), Self::Error> { + Ok(()) + } + + async fn start(&mut self) -> Result<(), Self::Error> { + let location = format!("http://{}:80/description.xml", self.ip); + + let legacy_api_version = self + .updater + .lock() + .await + .get() + .await + .get_legacy_apiversion(); + + let usn = format!("uuid:{}", self.usn); + let usn_rootdev = format!("{usn}::upnp:rootdevice"); + + // It's uncertain if these Device settings are valid according to the UPnP + // spec, but they exactly match the format sent out by real hue bridges + let server_fut = Server::new([ + Device::raw(&usn_rootdev, "upnp:rootdevice", &location), + Device::raw(&usn, &usn, &location), + Device::raw(&usn, "urn:schemas-upnp-org:device:basic:1", &location), + ]) + .extra_header("hue-bridgeid", hue::bridge_id(self.mac).to_uppercase()) + // enable workarounds to make Hue Essentials work + .partial_request_workaround(true) + // Hue Essentials strikes again: server name must look like this + .server_name(format!("Hue/1.0 UPnP/1.0 IpBridge/{legacy_api_version}")); + + let (tx, rx) = watch::channel(false); + self.shutdown = Some(rx); + self.signal = Some(tx); + + self.service = Some(server_fut); + + Ok(()) + } + + async fn run(&mut self) -> Result<(), Self::Error> { + if let (Some(svc), Some(shutdown)) = (&self.service, &mut self.shutdown) { + tokio::select! { + // wait for shutdown signal + res = shutdown.changed() => { + res.map_err(ApiError::service_error)?; + }, + + // wait for server to run (indefinitely) + res = svc.clone().serve_addr(self.ip)? => { + res?; + } + } + } + + Ok(()) + } + + async fn stop(&mut self) -> Result<(), Self::Error> { + Ok(()) + } + + async fn signal_stop(&mut self) -> Result { + if let Some(signal) = self.signal.take() { + log::debug!("Shutting down ssdp.."); + signal + .send(true) + .map_err(|_| ApiError::service_error("Failed to send stop signal"))?; + } + Ok(StopResult::Delivered) + } +} + +#[cfg(test)] +mod tests { + use mac_address::MacAddress; + use uuid::uuid; + + use crate::server::ssdp::hue_bridge_usn; + + #[test] + fn usn_generation() { + let expected = uuid!("2f402f80-da50-11e1-9b23-112233445566"); + let generated = hue_bridge_usn(MacAddress::new([0x11, 0x22, 0x33, 0x44, 0x55, 0x66])); + + assert_eq!(generated, expected); + } +} diff --git a/src/server/updater.rs b/src/server/updater.rs new file mode 100644 index 0000000..5314d9a --- /dev/null +++ b/src/server/updater.rs @@ -0,0 +1,88 @@ +use chrono::{DateTime, Duration, Utc}; + +use hue::HUE_BRIDGE_V2_MODEL_ID; +use hue::update::{UpdateEntries, UpdateEntry, update_url_for_bridge}; +use hue::version::SwVersion; + +use crate::error::{ApiError, ApiResult}; + +pub async fn fetch_updates(since_version: Option) -> ApiResult> { + let url = update_url_for_bridge(HUE_BRIDGE_V2_MODEL_ID, since_version.unwrap_or_default()); + let response: UpdateEntries = reqwest::get(url).await?.json().await?; + Ok(response.updates) +} + +#[derive(Debug)] +pub struct VersionUpdater { + version: Option, + last_fetch: Option>, +} + +impl Default for VersionUpdater { + fn default() -> Self { + Self::new() + } +} + +impl VersionUpdater { + const CACHE_TIME: Duration = Duration::hours(24); + + #[must_use] + pub const fn new() -> Self { + Self { + version: None, + last_fetch: None, + } + } + + #[must_use] + pub fn with_default_version() -> Self { + Self { + version: Some(SwVersion::default()), + last_fetch: Some(Utc::now()), + } + } + + pub const fn reset_cache(&mut self) { + self.last_fetch = None; + } + + pub async fn fetch_version(&mut self) -> ApiResult { + fetch_updates(None) + .await? + .into_iter() + .max_by(|x, y| x.version.cmp(&y.version)) + .map(|max| SwVersion::new(max.version, max.version_name)) + .ok_or(ApiError::NoUpdateInformation) + } + + pub async fn get(&mut self) -> &SwVersion { + let expired = self + .last_fetch + .is_none_or(|time| (Utc::now() - time) > Self::CACHE_TIME); + + if expired { + log::debug!("Firmware update information expired. Fetching.."); + } + + if expired || self.version.is_none() { + let version = match self.fetch_version().await { + Ok(version) => { + log::info!("Detected newest version to be {version:?}"); + version + } + Err(err) => { + let version = SwVersion::default(); + log::error!("Failed to fetch firmware changelog: {err}"); + log::warn!("Falling back to default version: {version:?}"); + version + } + }; + + self.last_fetch = Some(Utc::now()); + self.version = Some(version); + } + + self.version.as_ref().unwrap() + } +} diff --git a/utils/analyze-idv1.py b/utils/analyze-idv1.py new file mode 100755 index 0000000..2f71e5e --- /dev/null +++ b/utils/analyze-idv1.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 + +import sys +import json +import re +from rich import print + +RE_NUM = re.compile("/[0-9]+$") + +def load(name): + # print(f"loading {name}..") + return json.load(open(name))["data"] + +def idv1_type(idv1): + if not idv1.endswith("/0"): + idv1 = RE_NUM.sub("/:id", idv1) + if idv1.startswith("/scenes/"): + idv1 = "/scenes/:id" + return idv1 + + +def analyze_unique_ids(res): + ids = set() + + for obj in res: + if "id_v1" in obj: + rtype = obj["type"] + idv1 = idv1_type(obj["id_v1"]) + ids.add((idv1, rtype)) + + for idv1, rtype in sorted(ids): + print(f"{idv1:20} {rtype:30}") + + +def analyze_idv1_percent(res): + ids = dict() + + for obj in res: + rtype = obj["type"] + if rtype not in ids: + ids[rtype] = [0, 0] + + cnt = ids[rtype] + cnt[0] += 1 + if "id_v1" in obj: + cnt[1] += 1 + + for name, (total, idv1) in sorted(ids.items()): + pct = (idv1 / total) * 100.0 + print(f"{name:30} {pct:6.2f}% [{idv1:5} / {total:5}]") + + +def analyze_idv1_mapping(res): + ids = dict() + + for obj in res: + rtype = obj["type"] + if rtype not in ids: + ids[rtype] = set() + + if "id_v1" in obj: + idv1 = idv1_type(obj["id_v1"]) + else: + idv1 = "" + + ids[rtype].add(idv1) + + for name, types in sorted(ids.items()): + if types == {""}: + continue + print(f"{name:30} {' '.join(sorted(types, reverse=True))}") + + +def main(args): + objs = [] + for name in args: + objs.extend(load(name)) + + analyze_unique_ids(objs) + analyze_idv1_percent(objs) + analyze_idv1_mapping(objs) + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/utils/attribute-bruteforce.py b/utils/attribute-bruteforce.py new file mode 100755 index 0000000..0ace1b8 --- /dev/null +++ b/utils/attribute-bruteforce.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 + +import sys +import time +import json + +## Delay between each query, in seconds (adjust as needed) +## +## WARNING: Too small values might overload the zigbee network and/or the +## coordinator. +DELAY = 0.3 + +TMPL = { + "topic":"DUMMY/set", + "payload":{ + "read":{ + "cluster":64516, + "attributes":[], + "options":{ + "timeout": 500.0, + "manufacturerCode":4107 + }, + } + } +} + +def note(msg): + print(f">>> {msg} <<<", file=sys.stderr) + +def main(argv0, *args): + if len(args) != 1: + print(f"usage: {argv0} ") + return 1 + + topic = f"{args[0]}/set" + TMPL["topic"] = topic + note(f"Using topic {topic}") + + for cluster in [0xFC00, 0xFC01, 0xFC02, 0xFC03, 0xFC04]: + note(f"Brute forcing attributes in cluster {cluster:04X}") + TMPL["payload"]["read"]["cluster"] = cluster + for x in range(64): + TMPL["payload"]["read"]["attributes"] = [x] + print(json.dumps(TMPL)) + sys.stdout.flush() + time.sleep(DELAY) + + note("DONE") + return 0 + +if __name__ == "__main__": + sys.exit(main(*sys.argv)) diff --git a/utils/capture-event-stream.sh b/utils/capture-event-stream.sh new file mode 100755 index 0000000..6bc42c8 --- /dev/null +++ b/utils/capture-event-stream.sh @@ -0,0 +1,18 @@ +#!/bin/sh + +set -ue + +if [ $# -ne 2 ]; then + echo "usage: $0 " + exit 1 +fi + +KEY="${1}" +IP="${2}" + +exec curl -N --http2 \ + -H "Accept:text/event-stream" \ + -s \ + -k \ + -H"hue-application-key: ${KEY}" \ + "https://${IP}/eventstream/clip/v2" diff --git a/utils/extract-error-sample.py b/utils/extract-error-sample.py new file mode 100755 index 0000000..651b0c2 --- /dev/null +++ b/utils/extract-error-sample.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 + +import sys +import re +import enum +import json + +RE_LOG = re.compile("\s*[0-9]{4}-[0-9]{2}-[0-9]{2}.+(ERROR|INFO |TRACE|DEBUG|WARN ) ([a-z0-9_::-]+)\s+> (.+)") + +RE_FAILED_TO_PARSE = re.compile("\[(.+)\] Failed to parse \(non-critical\) z2m bridge message on \[(.+)\]:$") + +class Mode(enum.Enum): + Idle = 0 + IgnoreObj = 1 + IgnoreLogo = 2 + Sample = 3 + SampleStart = 4 + +mode = Mode.Idle + +res = [] +buf = None +topic = None + +BAR = " ===================================================================" + +for line in sys.stdin: + line = line.rstrip() + + if not line: + continue + + match mode: + case Mode.Idle: + if line == "{": + mode = Mode.IgnoreObj + + if line == BAR: + mode = Mode.IgnoreLogo + + if (log := RE_LOG.match(line)): + if (err := RE_FAILED_TO_PARSE.match(log.group(3))): + mode = Mode.SampleStart + buf = "" + topic = err.group(2) + + case Mode.IgnoreObj: + if line == "}": + mode = Mode.Idle + + case Mode.IgnoreLogo: + if line == BAR: + mode = Mode.Idle + + case Mode.SampleStart: + if (log := RE_LOG.match(line)): + mode = Mode.Sample + buf = log.group(3) + + case Mode.Sample: + if (log := RE_LOG.match(line)) or line == BAR: + try: + val = json.loads(buf) + res.append((topic, val)) + except: + print(f"FAILED TO PARSE JSON: {buf}") + mode = Mode.IgnoreLogo if line == BAR else Mode.Idle + buf = None + topic = None + else: + buf += line + +for topic, data in res: + print(json.dumps({"topic": topic, "payload": data})) diff --git a/utils/filter-zigbee.jq b/utils/filter-zigbee.jq new file mode 100755 index 0000000..3b7a978 --- /dev/null +++ b/utils/filter-zigbee.jq @@ -0,0 +1,24 @@ +#!/usr/bin/env -S jq -c --unbuffered -f + +### convert json file: +# +# tshark -r zigbee-2025-01-27_08.57.08-hue.pcap -lq -T ek -x | utils/pcap-extract-zigbee.jq > /tmp/sample.json +# + +### live capture +# +# tshark -i lo -lq -T ek -x 'udp port 1337' | utils/pcap-extract-zigbee.jq +# + +.layers + | select(.zbee_aps["zbee_aps_zbee_aps_cluster_raw"]) + | { + time: .frame["frame_frame_time_epoch"], + index: .frame["frame_frame_number"] | tonumber, + src_mac: (.zbee_nwk["zbee_nwk_zbee_sec_src64_raw"] // .wpan["wpan_wpan_addr64_raw"]), + src: (.zbee_nwk["zbee_nwk_zbee_nwk_src_raw"] // .wpan["wpan_wpan_addr16_raw"]), + dst: (.zbee_nwk["zbee_nwk_zbee_nwk_dst_raw"] // .wpan["wpan_wpan_dst16_raw"]), + cluster: .zbee_aps["zbee_aps_zbee_aps_cluster_raw"], + cmd: .zbee_zcl["zbee_zcl_zbee_zcl_cmd_id_raw"], + data: .zbee_zcl_raw + } diff --git a/utils/live-zigbee.sh b/utils/live-zigbee.sh new file mode 100755 index 0000000..cc3f957 --- /dev/null +++ b/utils/live-zigbee.sh @@ -0,0 +1,16 @@ +#!/bin/sh + +# this script expects a zigbee sniffer to be running, +# which dumps zigbee packets to udp on localhost +# +# one example is zsmartsystems zigbee sniffer: +# +# git clone https://github.com/zsmartsystems/com.zsmartsystems.zigbee.sniffer.git +# cd com.zsmartsystems.zigbee.sniffer +# +# java -jar com.zsmartsystems.zigbee.sniffer-1.0.2.jar -p /dev/serial/by-id/${ZIGBEE_ADAPTER} -c $ZIGBEE_CHANNEL -b 115200 -flow software -r 1337 +# + +BASEDIR="$(realpath -- $(dirname $0))" + +tshark -ni lo -lq -T ek -x | stdbuf -i0 -o0 ${BASEDIR}/filter-zigbee.jq | cargo run --example=ez-parse diff --git a/utils/pcap-extract-zigbee.sh b/utils/pcap-extract-zigbee.sh new file mode 100755 index 0000000..1887b92 --- /dev/null +++ b/utils/pcap-extract-zigbee.sh @@ -0,0 +1,12 @@ +#!/bin/sh + +BASEDIR="$(realpath -- $(dirname $0))" + +if [ $# -lt 1 ]; then + echo "usage: $0 " + exit 1 +fi + +for name in "$@"; do + tshark -r "${name}" -lq -T ek -x | "${BASEDIR}"/filter-zigbee.jq +done