first commit
This commit is contained in:
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ko_fi: chrivers
|
||||||
140
.github/workflows/docker-publish.yml
vendored
Normal file
140
.github/workflows/docker-publish.yml
vendored
Normal file
@@ -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 <account>/<repo>
|
||||||
|
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 ' *)
|
||||||
30
.github/workflows/rust.yml
vendored
Normal file
30
.github/workflows/rust.yml
vendored
Normal file
@@ -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
|
||||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/target
|
||||||
|
/cert.pem
|
||||||
|
/notes
|
||||||
|
/config.yaml
|
||||||
|
/state.yaml
|
||||||
|
/samples
|
||||||
|
/*.log
|
||||||
|
*~
|
||||||
|
/data
|
||||||
|
/*.json
|
||||||
3288
Cargo.lock
generated
Normal file
3288
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
144
Cargo.toml
Normal file
144
Cargo.toml
Normal file
@@ -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 <ci@iversenit.dk>"]
|
||||||
|
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"
|
||||||
29
Dockerfile
Normal file
29
Dockerfile
Normal file
@@ -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 \
|
||||||
|
<<EOF
|
||||||
|
set -e
|
||||||
|
apt-get -y update && apt-get install -y --no-install-recommends pkg-config libssl-dev
|
||||||
|
cargo build --locked --release
|
||||||
|
cp target/release/bifrost /bifrost
|
||||||
|
EOF
|
||||||
|
|
||||||
|
|
||||||
|
# Final Stage
|
||||||
|
FROM debian:bookworm-slim AS final
|
||||||
|
|
||||||
|
COPY --from=build /bifrost /app/bifrost
|
||||||
|
|
||||||
|
RUN apt-get -y update && apt-get install -y --no-install-recommends libssl3 ca-certificates
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
CMD ["/app/bifrost"]
|
||||||
674
LICENSE
Normal file
674
LICENSE
Normal file
@@ -0,0 +1,674 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
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.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
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
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||||
155
README.md
Normal file
155
README.md
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|

|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
[](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:
|
||||||
|
|
||||||
|
[](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!
|
||||||
1
crates/.gitignore
vendored
Normal file
1
crates/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
59
crates/README.md
Normal file
59
crates/README.md
Normal file
@@ -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.
|
||||||
34
crates/bifrost-api/Cargo.toml
Normal file
34
crates/bifrost-api/Cargo.toml
Normal file
@@ -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"]
|
||||||
39
crates/bifrost-api/src/backend.rs
Normal file
39
crates/bifrost-api/src/backend.rs
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
62
crates/bifrost-api/src/client.rs
Normal file
62
crates/bifrost-api/src/client.rs
Normal file
@@ -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<I: Serialize, O: DeserializeOwned>(
|
||||||
|
&self,
|
||||||
|
scope: &str,
|
||||||
|
method: Method,
|
||||||
|
data: Option<I>,
|
||||||
|
) -> BifrostResult<O> {
|
||||||
|
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<T: DeserializeOwned>(&self, scope: &str) -> BifrostResult<T> {
|
||||||
|
self.request(scope, Method::GET, None::<()>).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn post<I: Serialize, O: DeserializeOwned>(
|
||||||
|
&self,
|
||||||
|
scope: &str,
|
||||||
|
data: I,
|
||||||
|
) -> BifrostResult<O> {
|
||||||
|
self.request(scope, Method::POST, Some(data)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn put<I: Serialize, O: DeserializeOwned>(
|
||||||
|
&self,
|
||||||
|
scope: &str,
|
||||||
|
data: I,
|
||||||
|
) -> BifrostResult<O> {
|
||||||
|
self.request(scope, Method::PUT, Some(data)).await
|
||||||
|
}
|
||||||
|
}
|
||||||
120
crates/bifrost-api/src/config.rs
Normal file
120
crates/bifrost-api/src/config.rs
Normal file
@@ -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<String, Z2mServer>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)]
|
||||||
|
pub struct Z2mServer {
|
||||||
|
pub url: Url,
|
||||||
|
pub group_prefix: Option<String>,
|
||||||
|
pub disable_tls_verify: Option<bool>,
|
||||||
|
pub streaming_fps: Option<NonZeroU32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, Default, Eq, PartialEq)]
|
||||||
|
pub struct RoomConfig {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub icon: Option<RoomArchetype>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct AppConfig {
|
||||||
|
pub bridge: BridgeConfig,
|
||||||
|
pub z2m: Z2mConfig,
|
||||||
|
pub bifrost: BifrostConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub rooms: BTreeMap<String, RoomConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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], "<<REDACTED>>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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<AppConfig> {
|
||||||
|
self.get("config").await
|
||||||
|
}
|
||||||
|
}
|
||||||
15
crates/bifrost-api/src/error.rs
Normal file
15
crates/bifrost-api/src/error.rs
Normal file
@@ -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<T> = Result<T, BifrostError>;
|
||||||
13
crates/bifrost-api/src/lib.rs
Normal file
13
crates/bifrost-api/src/lib.rs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
38
crates/bifrost-api/src/service.rs
Normal file
38
crates/bifrost-api/src/service.rs
Normal file
@@ -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<Uuid, Service>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Client {
|
||||||
|
pub async fn service_list(&self) -> BifrostResult<ServiceList> {
|
||||||
|
self.get("service").await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn service_stop(&self, id: Uuid) -> BifrostResult<Uuid> {
|
||||||
|
self.put(&format!("service/{id}"), ServiceState::Stopped)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn service_start(&self, id: Uuid) -> BifrostResult<Uuid> {
|
||||||
|
self.put(&format!("service/{id}"), ServiceState::Running)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
14
crates/bifrost-api/src/websocket.rs
Normal file
14
crates/bifrost-api/src/websocket.rs
Normal file
@@ -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),
|
||||||
|
}
|
||||||
43
crates/hue/Cargo.toml
Normal file
43
crates/hue/Cargo.toml
Normal file
@@ -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"] }
|
||||||
231
crates/hue/src/api/behavior.rs
Normal file
231
crates/hue/src/api/behavior.rs
Normal file
@@ -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<u32>,
|
||||||
|
pub metadata: BehaviorScriptMetadata,
|
||||||
|
pub state_schema: DollarRef,
|
||||||
|
pub supported_features: Vec<String>,
|
||||||
|
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<Option<Value>, 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<BehaviorInstanceDependee>,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub last_error: Option<String>,
|
||||||
|
pub metadata: BehaviorInstanceMetadata,
|
||||||
|
pub script_id: Uuid,
|
||||||
|
pub status: Option<String>,
|
||||||
|
#[serde(
|
||||||
|
default,
|
||||||
|
deserialize_with = "deserialize_optional_field",
|
||||||
|
skip_serializing_if = "Option::is_none"
|
||||||
|
)]
|
||||||
|
pub state: Option<Value>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub migrated_from: Option<Value>,
|
||||||
|
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<configuration::Duration>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub style: Option<WakeupStyle>,
|
||||||
|
pub when: configuration::When,
|
||||||
|
#[serde(rename = "where")]
|
||||||
|
pub where_field: Vec<configuration::Where>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Vec<Weekday>>,
|
||||||
|
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<Vec<ResourceLink>>,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||||
|
pub struct BehaviorInstanceDependee {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub type_field: Option<String>,
|
||||||
|
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<Value>,
|
||||||
|
pub enabled: Option<bool>,
|
||||||
|
pub metadata: Option<BehaviorInstanceMetadata>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<BehaviorInstanceUpdate> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
216
crates/hue/src/api/device.rs
Normal file
216
crates/hue/src/api/device.rs
Normal file
@@ -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<ResourceLink>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub usertest: Option<UserTest>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub identify: Option<Stub>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||||
|
pub struct DeviceUpdate {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub metadata: Option<MetadataUpdate>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub services: Option<Vec<ResourceLink>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub product_data: Option<Value>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub identify: Option<DeviceIdentifyUpdate>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
}
|
||||||
30
crates/hue/src/api/entertainment.rs
Normal file
30
crates/hue/src/api/entertainment.rs
Normal file
@@ -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<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub renderer_reference: Option<ResourceLink>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub segments: Option<EntertainmentSegments>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct EntertainmentSegments {
|
||||||
|
pub configurable: bool,
|
||||||
|
pub max_segments: u32,
|
||||||
|
pub segments: Vec<EntertainmentSegment>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct EntertainmentSegment {
|
||||||
|
pub length: u32,
|
||||||
|
pub start: u32,
|
||||||
|
}
|
||||||
229
crates/hue/src/api/entertainment_config.rs
Normal file
229
crates/hue/src/api/entertainment_config.rs
Normal file
@@ -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<ResourceLink>,
|
||||||
|
pub channels: Vec<EntertainmentConfigurationChannels>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub active_streamer: Option<ResourceLink>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<EntertainmentConfigurationServiceLocations>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct EntertainmentConfigurationServiceLocations {
|
||||||
|
pub equalization_factor: f64,
|
||||||
|
pub position: Position,
|
||||||
|
pub positions: Vec<Position>,
|
||||||
|
pub service: ResourceLink,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct EntertainmentConfigurationChannels {
|
||||||
|
pub channel_id: u32,
|
||||||
|
pub position: Position,
|
||||||
|
pub members: Vec<EntertainmentConfigurationStreamMembers>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<EntertainmentConfigurationType>,
|
||||||
|
pub metadata: Option<EntertainmentConfigurationMetadata>,
|
||||||
|
pub action: Option<EntertainmentConfigurationAction>,
|
||||||
|
pub stream_proxy: Option<EntertainmentConfigurationStreamProxyUpdate>,
|
||||||
|
pub locations: Option<EntertainmentConfigurationLocationsUpdate>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<EntertainmentConfigurationStreamProxyUpdate>,
|
||||||
|
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<EntertainmentConfigurationServiceLocationsUpdate>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct EntertainmentConfigurationLocationsNew {
|
||||||
|
pub service_locations: Vec<EntertainmentConfigurationServiceLocationsNew>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<EntertainmentConfigurationServiceLocationsNew>
|
||||||
|
for EntertainmentConfigurationServiceLocationsUpdate
|
||||||
|
{
|
||||||
|
fn from(value: EntertainmentConfigurationServiceLocationsNew) -> Self {
|
||||||
|
Self {
|
||||||
|
equalization_factor: Some(1.0),
|
||||||
|
positions: value.positions,
|
||||||
|
service: value.service,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<EntertainmentConfigurationServiceLocations>
|
||||||
|
for EntertainmentConfigurationServiceLocationsUpdate
|
||||||
|
{
|
||||||
|
fn from(value: EntertainmentConfigurationServiceLocations) -> Self {
|
||||||
|
Self {
|
||||||
|
equalization_factor: Some(value.equalization_factor),
|
||||||
|
service: value.service,
|
||||||
|
positions: value.positions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<EntertainmentConfigurationServiceLocationsUpdate>
|
||||||
|
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<EntertainmentConfigurationServiceLocationsNew>
|
||||||
|
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<EntertainmentConfigurationLocationsNew> 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<f64>,
|
||||||
|
pub positions: Vec<Position>,
|
||||||
|
pub service: ResourceLink,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct EntertainmentConfigurationServiceLocationsNew {
|
||||||
|
pub positions: Vec<Position>,
|
||||||
|
pub service: ResourceLink,
|
||||||
|
}
|
||||||
149
crates/hue/src/api/grouped_light.rs
Normal file
149
crates/hue/src/api/grouped_light.rs
Normal file
@@ -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<DimmingUpdate>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub color: Option<Stub>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub color_temperature: Option<Stub>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub color_temperature_delta: Option<Stub>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub dimming_delta: Stub,
|
||||||
|
#[serde(default)]
|
||||||
|
pub dynamics: Stub,
|
||||||
|
pub on: Option<On>,
|
||||||
|
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<f64> {
|
||||||
|
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<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GroupedLightDynamicsUpdate {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_duration(self, duration: Option<impl Into<u32>>) -> 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<On>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub dimming: Option<DimmingUpdate>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub color: Option<ColorUpdate>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub color_temperature: Option<ColorTemperatureUpdate>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub owner: Option<ResourceLink>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub dynamics: Option<GroupedLightDynamicsUpdate>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GroupedLightUpdate {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_brightness(self, brightness: Option<f64>) -> Self {
|
||||||
|
Self {
|
||||||
|
dimming: brightness.map(DimmingUpdate::new),
|
||||||
|
..self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn with_on(self, on: Option<On>) -> Self {
|
||||||
|
Self { on, ..self }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn with_color_temperature(self, mirek: Option<u16>) -> 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<XY>) -> Self {
|
||||||
|
Self {
|
||||||
|
color: if let Some(xy) = val {
|
||||||
|
Some(ColorUpdate { xy })
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
..self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn with_dynamics(self, dynamics: Option<GroupedLightDynamicsUpdate>) -> 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))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
909
crates/hue/src/api/light.rs
Normal file
909
crates/hue/src/api/light.rs
Normal file
@@ -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<LightProductData>,
|
||||||
|
|
||||||
|
pub alert: Option<LightAlert>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub color: Option<LightColor>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub color_temperature: Option<ColorTemperature>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub color_temperature_delta: Option<Stub>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub dimming: Option<Dimming>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub dimming_delta: Option<Stub>,
|
||||||
|
pub dynamics: Option<LightDynamics>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub effects: Option<LightEffects>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub effects_v2: Option<LightEffectsV2>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub service_id: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub gradient: Option<LightGradient>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub identify: Identify,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub timed_effects: Option<LightTimedEffects>,
|
||||||
|
pub mode: LightMode,
|
||||||
|
pub on: On,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub powerup: Option<LightPowerup>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub signaling: Option<LightSignaling>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<LightFunction>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub fixed_mired: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||||
|
pub struct LightProductData {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub function: Option<LightFunction>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<LightMetadata> 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<DimmingUpdate> {
|
||||||
|
self.dimming.as_ref().map(|dim| DimmingUpdate {
|
||||||
|
brightness: dim.brightness,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_mirek_opt(&self) -> Option<u16> {
|
||||||
|
self.color_temperature.as_ref().and_then(|ct| ct.mirek)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_color_opt(&self) -> Option<XY> {
|
||||||
|
self.color.as_ref().map(|col| col.xy)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_gradient_opt(&self) -> Option<LightGradientUpdate> {
|
||||||
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<LightGradientMode>,
|
||||||
|
pub points_capable: u32,
|
||||||
|
pub points: Vec<LightGradientPoint>,
|
||||||
|
pub pixel_count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct LightGradientUpdate {
|
||||||
|
#[serde(default)]
|
||||||
|
pub mode: Option<LightGradientMode>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub points: Vec<LightGradientPoint>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<LightSignal>,
|
||||||
|
#[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<LightDynamicsStatus>,
|
||||||
|
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<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub duration: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LightDynamicsUpdate {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_duration(self, duration: Option<impl Into<u32>>) -> 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<LightEffect>,
|
||||||
|
pub status: LightEffect,
|
||||||
|
pub effect_values: Vec<LightEffect>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<LightEffectActionUpdate>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub status: Option<LightEffect>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct LightEffectsV2Update {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub action: Option<LightEffectActionUpdate>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub status: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct LightEffectActionUpdate {
|
||||||
|
#[serde(default)]
|
||||||
|
pub effect: Option<LightEffect>,
|
||||||
|
pub parameters: LightEffectParameters,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct LightEffectParameters {
|
||||||
|
#[serde(default)]
|
||||||
|
pub color: Option<ColorUpdate>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub color_temperature: Option<ColorTemperatureUpdate>,
|
||||||
|
pub speed: Option<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||||
|
pub struct LightEffectValues {
|
||||||
|
pub effect_values: Vec<LightEffect>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||||
|
pub struct LightEffectStatus {
|
||||||
|
pub effect: LightEffect,
|
||||||
|
pub effect_values: Vec<LightEffect>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub parameters: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<LightTimedEffect>,
|
||||||
|
pub status: LightTimedEffect,
|
||||||
|
pub effect_values: Vec<LightTimedEffect>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||||
|
pub struct LightTimedEffectsUpdate {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub effect: Option<LightTimedEffect>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub duration: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||||
|
pub struct LightUpdate {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub metadata: Option<MetadataUpdate>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub on: Option<On>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub dimming: Option<DimmingUpdate>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub color: Option<ColorUpdate>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub color_temperature: Option<ColorTemperatureUpdate>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub gradient: Option<LightGradientUpdate>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub effects: Option<LightEffectsUpdate>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub effects_v2: Option<LightEffectsV2Update>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub service_id: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub owner: Option<ResourceLink>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub powerup: Option<Value>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub dynamics: Option<LightDynamicsUpdate>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub identify: Option<DeviceIdentifyUpdate>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub timed_effects: Option<LightTimedEffectsUpdate>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LightUpdate {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_brightness(self, dim: Option<impl Into<f64>>) -> Self {
|
||||||
|
Self {
|
||||||
|
dimming: dim.map(Into::into).map(DimmingUpdate::new),
|
||||||
|
..self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_on(self, on: impl Into<Option<On>>) -> Self {
|
||||||
|
Self {
|
||||||
|
on: on.into(),
|
||||||
|
..self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_color_temperature(self, mirek: impl Into<Option<u16>>) -> Self {
|
||||||
|
Self {
|
||||||
|
color_temperature: mirek.into().map(ColorTemperatureUpdate::new),
|
||||||
|
..self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_color_xy(self, xy: impl Into<Option<XY>>) -> Self {
|
||||||
|
Self {
|
||||||
|
color: self.color.or_else(|| xy.into().map(ColorUpdate::new)),
|
||||||
|
..self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_color_hs(self, hs: impl Into<Option<HS>>) -> 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<DeviceIdentifyUpdate>) -> Self {
|
||||||
|
Self { identify, ..self }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_gradient(self, gradient: Option<LightGradientUpdate>) -> Self {
|
||||||
|
Self { gradient, ..self }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_dynamics(self, dynamics: Option<LightDynamicsUpdate>) -> 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<Dimming> 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<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ColorGamut>,
|
||||||
|
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<u16>,
|
||||||
|
pub mirek_schema: MirekSchema,
|
||||||
|
pub mirek_valid: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ColorTemperature> for Option<ColorTemperatureUpdate> {
|
||||||
|
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<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Dimming> for f64 {
|
||||||
|
fn from(value: Dimming) -> Self {
|
||||||
|
value.brightness
|
||||||
|
}
|
||||||
|
}
|
||||||
410
crates/hue/src/api/mod.rs
Normal file
410
crates/hue/src/api/mod.rs
Normal file
@@ -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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
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<ResourceLink> {
|
||||||
|
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<Self> {
|
||||||
|
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<Self, Self::Error> {
|
||||||
|
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<Self, Self::Error> {
|
||||||
|
if let Resource::$name(obj) = value {
|
||||||
|
Ok(obj)
|
||||||
|
} else {
|
||||||
|
Err(HueError::WrongType(RType::Light, value.rtype()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<Resource> for $name {
|
||||||
|
type Error = HueError;
|
||||||
|
|
||||||
|
fn try_from(value: Resource) -> Result<Self, Self::Error> {
|
||||||
|
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> {
|
||||||
|
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<T: Serialize>(mut self, name: &'a str, value: T) -> HueResult<Self> {
|
||||||
|
self.success.push((name, serde_json::to_value(value)?));
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_option<T: Serialize>(mut self, name: &'a str, value: Option<T>) -> HueResult<Self> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
220
crates/hue/src/api/resource.rs
Normal file
220
crates/hue/src/api/resource.rs
Normal file
@@ -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<H: Hasher>(&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: Hash + ?Sized>(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<String>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub obj: Resource,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResourceRecord {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(id: Uuid, id_v1: Option<String>, 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
196
crates/hue/src/api/room.rs
Normal file
196
crates/hue/src/api/room.rs
Normal file
@@ -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<String>,
|
||||||
|
pub archetype: Option<RoomArchetype>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Room {
|
||||||
|
pub children: BTreeSet<ResourceLink>,
|
||||||
|
pub metadata: RoomMetadata,
|
||||||
|
#[serde(default)]
|
||||||
|
pub services: BTreeSet<ResourceLink>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||||
|
pub struct RoomUpdate {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub children: Option<BTreeSet<ResourceLink>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub metadata: Option<RoomMetadataUpdate>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub services: Option<Vec<ResourceLink>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ResourceLink>) -> 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
|
||||||
|
}
|
||||||
|
}
|
||||||
218
crates/hue/src/api/scene.rs
Normal file
218
crates/hue/src/api/scene.rs
Normal file
@@ -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<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<SceneActionElement>,
|
||||||
|
#[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<SceneStatus>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub recall: SceneRecall,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct SceneAction {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub color: Option<ColorUpdate>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub color_temperature: Option<ColorTemperatureUpdate>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub dimming: Option<DimmingUpdate>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub on: Option<On>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub gradient: Option<LightGradientUpdate>,
|
||||||
|
#[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<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub image: Option<ResourceLink>,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)]
|
||||||
|
pub struct SceneMetadataUpdate {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub appdata: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub image: Option<ResourceLink>,
|
||||||
|
pub name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||||
|
pub struct SceneUpdate {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub actions: Option<Vec<SceneActionElement>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub recall: Option<SceneRecall>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub metadata: Option<SceneMetadataUpdate>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub palette: Option<Value>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub speed: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub auto_dynamic: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SceneUpdate {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_actions(self, actions: Option<Vec<SceneActionElement>>) -> Self {
|
||||||
|
Self { actions, ..self }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_recall_action(self, action: Option<SceneStatus>) -> 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<SceneStatusEnum>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub duration: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub dimming: Option<DimmingUpdate>,
|
||||||
|
}
|
||||||
58
crates/hue/src/api/stream.rs
Normal file
58
crates/hue/src/api/stream.rs
Normal file
@@ -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<Self, Self::Error> {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
249
crates/hue/src/api/stubs.rs
Normal file
249
crates/hue/src/api/stubs.rs
Normal file
@@ -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<ResourceLink>,
|
||||||
|
pub services: BTreeSet<ResourceLink>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<ButtonReport>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub last_event: Option<Value>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub repeat_interval: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub event_values: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct ButtonReport {
|
||||||
|
#[serde(with = "date_format::utc_ms")]
|
||||||
|
pub updated: DateTime<Utc>,
|
||||||
|
pub event: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct DollarRef {
|
||||||
|
#[serde(rename = "$ref", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub dref: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Value>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub rotary_report: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
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<Value>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub extended_pan_id: Option<String>,
|
||||||
|
pub mac_address: String,
|
||||||
|
pub owner: ResourceLink,
|
||||||
|
pub status: ZigbeeConnectivityStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct Zone {
|
||||||
|
pub metadata: Metadata,
|
||||||
|
pub children: BTreeSet<ResourceLink>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub services: BTreeSet<ResourceLink>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub archetype: Option<DeviceArchetype>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub function: Option<LightFunction>,
|
||||||
|
}
|
||||||
61
crates/hue/src/api/update.rs
Normal file
61
crates/hue/src/api/update.rs
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
60
crates/hue/src/api/zigbee_device_discovery.rs
Normal file
60
crates/hue/src/api/zigbee_device_discovery.rs
Normal file
@@ -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<Value>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub search_codes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct ZigbeeDeviceDiscoveryUpdate {
|
||||||
|
pub action: ZigbeeDeviceDiscoveryUpdateAction,
|
||||||
|
pub add_install_code: Option<ZigbeeDeviceDiscoveryInstallCode>,
|
||||||
|
}
|
||||||
97
crates/hue/src/clamp.rs
Normal file
97
crates/hue/src/clamp.rs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
259
crates/hue/src/colorspace.rs
Normal file
259
crates/hue/src/colorspace.rs
Normal file
@@ -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<Self> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
95
crates/hue/src/colortemp.rs
Normal file
95
crates/hue/src/colortemp.rs
Normal file
@@ -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:
|
||||||
|
///
|
||||||
|
/// <https://github.com/colour-science/colour/blob/develop/colour/temperature/kang2002.py>
|
||||||
|
///
|
||||||
|
/// 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:
|
||||||
|
//
|
||||||
|
// <http://www.vendian.org/mncharity/dir3/blackbody/UnstableURLs/bbr_color.html>
|
||||||
|
//
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
324
crates/hue/src/date_format.rs
Normal file
324
crates/hue/src/date_format.rs
Normal file
@@ -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<S>(date: &$type, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
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<S>(date: &Option<$type>, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
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<Option<$type>, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
use serde::{self, Deserialize, de::Error};
|
||||||
|
let Some(s) = Option::<String>::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<Option<$type>, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
use serde::{self, Deserialize, de::Error};
|
||||||
|
let Some(s) = Option::<String>::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<Utc>, super::FORMAT_MS);
|
||||||
|
date_deserializer_utc!(DateTime<Utc>, 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<DateTime<Utc>, 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::<Utc>::from_naive_utc_and_offset(dt, Utc))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod utc {
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
|
date_serializer!(DateTime<Utc>, super::FORMAT);
|
||||||
|
date_deserializer_utc!(DateTime<Utc>, super::FORMAT);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod utc_ms_opt {
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
|
date_serializer_opt!(DateTime<Utc>, super::FORMAT_MS);
|
||||||
|
date_deserializer_utc_opt!(DateTime<Utc>, 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<Local>, super::FORMAT_LOCAL);
|
||||||
|
date_deserializer_local_opt!(DateTime<Local>, super::FORMAT_LOCAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod legacy_utc {
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
|
date_serializer!(DateTime<Utc>, super::FORMAT_LOCAL);
|
||||||
|
date_deserializer_utc!(DateTime<Utc>, super::FORMAT_LOCAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod legacy_utc_opt {
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
|
date_serializer_opt!(DateTime<Utc>, super::FORMAT_LOCAL);
|
||||||
|
date_deserializer_utc_opt!(DateTime<Utc>, 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<T: Debug + Eq>(
|
||||||
|
ds: &'static str,
|
||||||
|
d1: &T,
|
||||||
|
desi: impl Fn(&mut serde_json::Deserializer<StrRead>) -> serde_json::Result<T>,
|
||||||
|
) -> 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<u8>>) -> 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<Utc>) {
|
||||||
|
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<Utc>) {
|
||||||
|
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<Local>) {
|
||||||
|
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<Utc>) {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
124
crates/hue/src/devicedb.rs
Normal file
124
crates/hue/src/devicedb.rs
Normal file
@@ -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<BTreeMap<&str, SimpleProductData>> = 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<SimpleProductData<'static>> {
|
||||||
|
PRODUCT_DATA.get(model_id).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn product_archetype(model_id: &str) -> Option<DeviceArchetype> {
|
||||||
|
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",);
|
||||||
|
}
|
||||||
|
}
|
||||||
242
crates/hue/src/diff.rs
Normal file
242
crates/hue/src/diff.rs
Normal file
@@ -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<Option<Value>> {
|
||||||
|
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::<BTreeSet<String>>();
|
||||||
|
let kb = b.keys().cloned().collect::<BTreeSet<String>>();
|
||||||
|
|
||||||
|
// 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<T: Serialize + DeserializeOwned>(ma: &T, mb: Value) -> HueResult<T> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
350
crates/hue/src/effect_duration.rs
Normal file
350
crates/hue/src/effect_duration.rs
Normal file
@@ -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<Self> {
|
||||||
|
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<Self> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
119
crates/hue/src/error.rs
Normal file
119
crates/hue/src/error.rs
Normal file
@@ -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<T> = Result<T, HueError>;
|
||||||
179
crates/hue/src/event.rs
Normal file
179
crates/hue/src/event.rs
Normal file
@@ -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<Utc>,
|
||||||
|
pub id: Uuid,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub event: Event,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "rng")]
|
||||||
|
impl EventBlock {
|
||||||
|
#[must_use]
|
||||||
|
pub fn add(data: Vec<ResourceRecord>) -> Self {
|
||||||
|
Self {
|
||||||
|
creationtime: Utc::now(),
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
event: Event::Add(Add { data }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update(id: &Uuid, id_v1: Option<String>, rtype: RType, data: Value) -> HueResult<Self> {
|
||||||
|
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<String>) -> HueResult<Self> {
|
||||||
|
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<ResourceRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct ObjectUpdate {
|
||||||
|
pub id: Uuid,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub id_v1: Option<String>,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub rtype: RType,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub data: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct Update {
|
||||||
|
pub data: Vec<ObjectUpdate>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct ObjectDelete {
|
||||||
|
pub id: Uuid,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub id_v1: Option<String>,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub rtype: RType,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct Delete {
|
||||||
|
pub data: Vec<ObjectDelete>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
crates/hue/src/flags.rs
Normal file
46
crates/hue/src/flags.rs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
pub trait TakeFlag {
|
||||||
|
fn take(&mut self, flag: Self) -> bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: bitflags::Flags + Copy> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
121
crates/hue/src/gamma.rs
Normal file
121
crates/hue/src/gamma.rs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
crates/hue/src/hs.rs
Normal file
58
crates/hue/src/hs.rs
Normal file
@@ -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<RawHS> 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
930
crates/hue/src/legacy_api.rs
Normal file
930
crates/hue/src/legacy_api.rs
Normal file
@@ -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<T> {
|
||||||
|
Success(T),
|
||||||
|
Error(HueError),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "mac")]
|
||||||
|
pub fn serialize_lower_case_mac<S>(mac: &MacAddress, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
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<String>,
|
||||||
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Utc>,
|
||||||
|
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<Utc>,
|
||||||
|
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<Utc>,
|
||||||
|
#[serde(with = "date_format::legacy_utc", rename = "last use date")]
|
||||||
|
pub last_use_date: DateTime<Utc>,
|
||||||
|
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<Utc>,
|
||||||
|
#[serde(with = "date_format::legacy_naive")]
|
||||||
|
pub localtime: NaiveDateTime,
|
||||||
|
pub whitelist: HashMap<String, Whitelist>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub hue: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub sat: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub effect: Option<ApiEffect>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub xy: Option<[f64; 2]>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub ct: Option<u16>,
|
||||||
|
pub alert: ApiAlert,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub colormode: Option<LightColorMode>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
pub action: ApiGroupAction,
|
||||||
|
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub group_type: ApiGroupType,
|
||||||
|
pub class: ApiGroupClass,
|
||||||
|
pub recycle: bool,
|
||||||
|
pub sensors: Vec<Value>,
|
||||||
|
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<String>,
|
||||||
|
#[serde(default, rename = "type")]
|
||||||
|
pub group_type: ApiGroupType,
|
||||||
|
#[serde(default)]
|
||||||
|
pub class: ApiGroupClass,
|
||||||
|
pub lights: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String>,
|
||||||
|
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<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
hue: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
sat: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
effect: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
xy: Option<[f64; 2]>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
ct: Option<u16>,
|
||||||
|
alert: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
colormode: Option<LightColorMode>,
|
||||||
|
mode: String,
|
||||||
|
reachable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ApiLightStateUpdate {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub on: Option<bool>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub bri: Option<u8>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub xy: Option<[f64; 2]>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub ct: Option<u16>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none", flatten)]
|
||||||
|
pub hs: Option<RawHS>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub transitiontime: Option<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Vec<String>>,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub stream: Option<Active>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub enum ApiGroupActionUpdate {
|
||||||
|
GroupUpdate(ApiGroupUpdate),
|
||||||
|
LightUpdate(ApiLightStateUpdate),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<api::SceneAction> 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<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
productid: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ApiRule {
|
||||||
|
pub name: String,
|
||||||
|
pub recycle: bool,
|
||||||
|
pub status: String,
|
||||||
|
pub conditions: Vec<Value>,
|
||||||
|
pub actions: Vec<Value>,
|
||||||
|
pub owner: Uuid,
|
||||||
|
pub timestriggered: u32,
|
||||||
|
#[serde(with = "date_format::legacy_utc")]
|
||||||
|
pub created: DateTime<Utc>,
|
||||||
|
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<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub version: Option<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ApiScene {
|
||||||
|
pub name: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub scene_type: ApiSceneType,
|
||||||
|
pub lights: Vec<String>,
|
||||||
|
#[serde(skip_serializing_if = "HashMap::is_empty", default)]
|
||||||
|
pub lightstates: HashMap<String, ApiLightStateUpdate>,
|
||||||
|
pub owner: String,
|
||||||
|
pub recycle: bool,
|
||||||
|
pub locked: bool,
|
||||||
|
pub appdata: ApiSceneAppData,
|
||||||
|
pub picture: String,
|
||||||
|
#[serde(with = "date_format::legacy_utc")]
|
||||||
|
pub lastupdated: DateTime<Utc>,
|
||||||
|
pub version: u32,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub image: Option<Uuid>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub group: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ApiSchedule {
|
||||||
|
pub recycle: bool,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub autodelete: Option<bool>,
|
||||||
|
pub description: String,
|
||||||
|
pub command: Value,
|
||||||
|
#[serde(with = "date_format::legacy_utc")]
|
||||||
|
pub created: DateTime<Utc>,
|
||||||
|
#[serde(
|
||||||
|
with = "date_format::legacy_utc_opt",
|
||||||
|
default,
|
||||||
|
skip_serializing_if = "Option::is_none"
|
||||||
|
)]
|
||||||
|
pub starttime: Option<DateTime<Utc>>,
|
||||||
|
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<SwUpdate>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub uniqueid: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub diversityid: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub productname: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub recycle: Option<bool>,
|
||||||
|
#[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<String, ApiGroup>,
|
||||||
|
pub lights: HashMap<String, ApiLight>,
|
||||||
|
pub resourcelinks: HashMap<u32, ApiResourceLink>,
|
||||||
|
pub rules: HashMap<u32, ApiRule>,
|
||||||
|
pub scenes: HashMap<String, ApiScene>,
|
||||||
|
pub schedules: HashMap<u32, ApiSchedule>,
|
||||||
|
pub sensors: HashMap<u32, ApiSensor>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
167
crates/hue/src/lib.rs
Normal file
167
crates/hue/src/lib.rs
Normal file
@@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
crates/hue/src/scene_icons.rs
Normal file
14
crates/hue/src/scene_icons.rs
Normal file
@@ -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");
|
||||||
483
crates/hue/src/stream.rs
Normal file
483
crates/hue/src/stream.rs
Normal file
@@ -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::<<Self as PackedStruct>::ByteArray>();
|
||||||
|
|
||||||
|
pub fn parse(data: &[u8]) -> HueResult<Self> {
|
||||||
|
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<u32> {
|
||||||
|
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<Self> {
|
||||||
|
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<Rgb16V1>),
|
||||||
|
Xy(Vec<Xy16V1>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub enum HueStreamLightsV2 {
|
||||||
|
Rgb(Vec<Rgb16V2>),
|
||||||
|
Xy(Vec<Xy16V2>),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_list<T: PackedStruct>(data: &[u8]) -> HueResult<Vec<T>> {
|
||||||
|
let res = data
|
||||||
|
.chunks_exact(T::ByteArray::len())
|
||||||
|
.map(T::unpack_from_slice)
|
||||||
|
.collect::<Result<_, _>>()?;
|
||||||
|
|
||||||
|
Ok(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HueStreamLightsV1 {
|
||||||
|
pub fn parse(color_mode: HueStreamColorMode, data: &[u8]) -> HueResult<Self> {
|
||||||
|
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<Self> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
45
crates/hue/src/update.rs
Normal file
45
crates/hue/src/update.rs
Normal file
@@ -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<Utc>,
|
||||||
|
#[serde(with = "date_format::update_utc")]
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
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<UpdateEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
148
crates/hue/src/version.rs
Normal file
148
crates/hue/src/version.rs
Normal file
@@ -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<std::cmp::Ordering> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
255
crates/hue/src/xy.rs
Normal file
255
crates/hue/src/xy.rs
Normal file
@@ -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<XY> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
711
crates/hue/src/zigbee/composite.rs
Normal file
711
crates/hue/src/zigbee/composite.rs
Normal file
@@ -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<LightEffect> 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<LightTimedEffect> 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<LightGradientMode> 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<XY>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<u8>,
|
||||||
|
pub brightness: Option<u8>,
|
||||||
|
pub color_mirek: Option<u16>,
|
||||||
|
pub color_xy: Option<XY>,
|
||||||
|
pub fade_speed: Option<u16>,
|
||||||
|
pub gradient_colors: Option<GradientColors>,
|
||||||
|
pub gradient_params: Option<GradientParams>,
|
||||||
|
pub effect_type: Option<EffectType>,
|
||||||
|
pub effect_speed: Option<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<XY>,
|
||||||
|
) -> HueResult<Self> {
|
||||||
|
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<Self> {
|
||||||
|
let mut hz = Self::default();
|
||||||
|
|
||||||
|
let mut flags = Flags::from_bits(rdr.read_u16::<LE>()?).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::<LE>()?);
|
||||||
|
}
|
||||||
|
|
||||||
|
if flags.take(Flags::COLOR_XY) {
|
||||||
|
hz.color_xy = Some(XY::new(
|
||||||
|
f64::from(rdr.read_u16::<LE>()?) / f64::from(0xFFFF),
|
||||||
|
f64::from(rdr.read_u16::<LE>()?) / f64::from(0xFFFF),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if flags.take(Flags::FADE_SPEED) {
|
||||||
|
hz.fade_speed = Some(rdr.read_u16::<LE>()?);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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<T>(flags: &mut Flags, opt: &Option<T>, 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::<LE>(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::<LE>(mirek)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(xy) = self.color_xy {
|
||||||
|
wtr.write_u16::<LE>((xy.x * f64::from(0xFFFF)) as u16)?;
|
||||||
|
wtr.write_u16::<LE>((xy.y * f64::from(0xFFFF)) as u16)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(fade_speed) = self.fade_speed {
|
||||||
|
wtr.write_u16::<LE>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
411
crates/hue/src/zigbee/entertainment.rs
Normal file
411
crates/hue/src/zigbee/entertainment.rs
Normal file
@@ -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<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<HueEntSegment>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<HueEntFrameLightRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<LightRecordMode> {
|
||||||
|
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<Self> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
let mut res = vec![];
|
||||||
|
let count = u16::try_from(self.members.len())?;
|
||||||
|
res.write_u16::<BE>(count)?;
|
||||||
|
for m in &self.members {
|
||||||
|
res.write_u16::<LE>(*m)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HueEntSegmentLayout {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(map: &[HueEntSegment]) -> Self {
|
||||||
|
Self {
|
||||||
|
members: map.to_vec(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(data: &[u8]) -> HueResult<Self> {
|
||||||
|
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::<Result<_, _>>()?;
|
||||||
|
|
||||||
|
Ok(Self { members })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pack(&self) -> HueResult<Vec<u8>> {
|
||||||
|
let mut res = vec![];
|
||||||
|
let count = u8::try_from(self.members.len())?;
|
||||||
|
res.write_u16::<LE>(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<Self> {
|
||||||
|
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::<Result<_, _>>()?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
counter: hdr.counter,
|
||||||
|
smoothing: hdr.smoothing,
|
||||||
|
blks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pack(&self) -> HueResult<Vec<u8>> {
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
crates/hue/src/zigbee/mod.rs
Normal file
9
crates/hue/src/zigbee/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
mod composite;
|
||||||
|
mod entertainment;
|
||||||
|
mod stream;
|
||||||
|
mod target;
|
||||||
|
|
||||||
|
pub use composite::*;
|
||||||
|
pub use entertainment::*;
|
||||||
|
pub use stream::*;
|
||||||
|
pub use target::*;
|
||||||
297
crates/hue/src/zigbee/stream.rs
Normal file
297
crates/hue/src/zigbee/stream.rs
Normal file
@@ -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<u8>,
|
||||||
|
|
||||||
|
/// Disable default response
|
||||||
|
pub ddr: bool,
|
||||||
|
|
||||||
|
/// Frametype
|
||||||
|
pub frametype: u8,
|
||||||
|
|
||||||
|
/// Manufacturer Code
|
||||||
|
pub mfc: Option<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ZigbeeMessage {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(cluster: u16, command: u8, data: Vec<u8>) -> 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<u16>) -> 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<u16> {
|
||||||
|
// 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<ZigbeeMessage> {
|
||||||
|
let msg = HueEntSegmentConfig::new(map);
|
||||||
|
|
||||||
|
Ok(ZigbeeMessage::new(
|
||||||
|
Self::CLUSTER,
|
||||||
|
Self::CMD_SEGMENT_MAP,
|
||||||
|
msg.pack()?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset(&mut self) -> HueResult<ZigbeeMessage> {
|
||||||
|
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<HueEntFrameLightRecord>) -> HueResult<ZigbeeMessage> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
13
crates/hue/src/zigbee/target.rs
Normal file
13
crates/hue/src/zigbee/target.rs
Normal file
@@ -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<Self::Output, Self::Error>;
|
||||||
|
}
|
||||||
32
crates/svc/Cargo.toml
Normal file
32
crates/svc/Cargo.toml
Normal file
@@ -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"
|
||||||
50
crates/svc/examples/from_async_fn.rs
Normal file
50
crates/svc/examples/from_async_fn.rs
Normal file
@@ -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(())
|
||||||
|
}
|
||||||
70
crates/svc/examples/policy.rs
Normal file
70
crates/svc/examples/policy.rs
Normal file
@@ -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(())
|
||||||
|
}
|
||||||
56
crates/svc/examples/restart.rs
Normal file
56
crates/svc/examples/restart.rs
Normal file
@@ -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(())
|
||||||
|
}
|
||||||
86
crates/svc/examples/simple.rs
Normal file
86
crates/svc/examples/simple.rs
Normal file
@@ -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(())
|
||||||
|
}
|
||||||
84
crates/svc/src/error.rs
Normal file
84
crates/svc/src/error.rs
Normal file
@@ -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<SvmRequest>),
|
||||||
|
|
||||||
|
#[error(transparent)]
|
||||||
|
MpscSendEventError(#[from] tokio::sync::mpsc::error::SendError<ServiceEvent>),
|
||||||
|
|
||||||
|
#[error(transparent)]
|
||||||
|
WatchSendError(#[from] tokio::sync::watch::error::SendError<ServiceState>),
|
||||||
|
|
||||||
|
#[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<dyn Error + Send>),
|
||||||
|
}
|
||||||
|
|
||||||
|
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<SvmRequest>),
|
||||||
|
|
||||||
|
#[error(transparent)]
|
||||||
|
WatchSendError(#[from] tokio::sync::watch::error::SendError<ServiceState>),
|
||||||
|
|
||||||
|
#[error(transparent)]
|
||||||
|
MpscSendEventError(#[from] tokio::sync::mpsc::error::SendError<ServiceEvent>),
|
||||||
|
|
||||||
|
#[error(transparent)]
|
||||||
|
WatchRecvError(#[from] tokio::sync::watch::error::RecvError),
|
||||||
|
|
||||||
|
/* errors from run service */
|
||||||
|
#[error(transparent)]
|
||||||
|
ServiceError(Box<dyn Error + Send>),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type SvcResult<T> = Result<T, SvcError>;
|
||||||
14
crates/svc/src/lib.rs
Normal file
14
crates/svc/src/lib.rs
Normal file
@@ -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;
|
||||||
574
crates/svc/src/manager.rs
Normal file
574
crates/svc/src/manager.rs
Normal file
@@ -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<ServiceState>,
|
||||||
|
name: ServiceName,
|
||||||
|
state: ServiceState,
|
||||||
|
abort_handle: AbortHandle,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type ServiceFunc = Box<
|
||||||
|
dyn FnOnce(
|
||||||
|
Uuid,
|
||||||
|
watch::Receiver<ServiceState>,
|
||||||
|
mpsc::UnboundedSender<ServiceEvent>,
|
||||||
|
) -> 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<ServiceId, SvcResult<Uuid>>),
|
||||||
|
Start(RpcRequest<ServiceId, SvcResult<Uuid>>),
|
||||||
|
Status(RpcRequest<ServiceId, SvcResult<ServiceState>>),
|
||||||
|
List(RpcRequest<(), Vec<(Uuid, ServiceName)>>),
|
||||||
|
Resolve(RpcRequest<ServiceId, SvcResult<Uuid>>),
|
||||||
|
LookupName(RpcRequest<ServiceId, SvcResult<ServiceName>>),
|
||||||
|
Register(RpcRequest<(String, ServiceFunc), SvcResult<Uuid>>),
|
||||||
|
RegisterTemplate(RpcRequest<(String, Box<dyn ServiceTemplate>), SvcResult<()>>),
|
||||||
|
Subscribe(RpcRequest<mpsc::UnboundedSender<ServiceEvent>, SvcResult<Uuid>>),
|
||||||
|
Shutdown(RpcRequest<(), ()>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SvmClient {
|
||||||
|
tx: mpsc::UnboundedSender<SvmRequest>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SvmClient {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(tx: mpsc::UnboundedSender<SvmRequest>) -> Self {
|
||||||
|
Self { tx }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn rpc<Q, A>(
|
||||||
|
&mut self,
|
||||||
|
func: impl FnOnce(RpcRequest<Q, A>) -> SvmRequest,
|
||||||
|
args: Q,
|
||||||
|
) -> SvcResult<A> {
|
||||||
|
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<S>(&mut self, name: impl AsRef<str>, svc: S) -> SvcResult<Uuid>
|
||||||
|
where
|
||||||
|
S: Service + 'static,
|
||||||
|
{
|
||||||
|
self.register(&name, StandardService::new(&name, svc)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn register_function<F, E>(
|
||||||
|
&mut self,
|
||||||
|
name: impl AsRef<str>,
|
||||||
|
func: F,
|
||||||
|
) -> SvcResult<Uuid>
|
||||||
|
where
|
||||||
|
F: Future<Output = Result<(), E>> + Send + 'static,
|
||||||
|
E: Error + Send + 'static,
|
||||||
|
{
|
||||||
|
self.register(&name, StandardService::new(&name, Box::pin(func)))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn register<S>(&mut self, name: impl AsRef<str>, svc: S) -> SvcResult<Uuid>
|
||||||
|
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<str>,
|
||||||
|
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<Uuid> {
|
||||||
|
self.rpc(SvmRequest::Start, id.service_id()).await?
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn stop(&mut self, id: impl IntoServiceId) -> SvcResult<Uuid> {
|
||||||
|
self.rpc(SvmRequest::Stop, id.service_id()).await?
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn resolve(&mut self, id: impl IntoServiceId) -> SvcResult<Uuid> {
|
||||||
|
self.rpc(SvmRequest::Resolve, id.service_id()).await?
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn lookup_name(&mut self, id: impl IntoServiceId) -> SvcResult<ServiceName> {
|
||||||
|
self.rpc(SvmRequest::LookupName, id.service_id()).await?
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn subscribe(&mut self) -> SvcResult<(Uuid, mpsc::UnboundedReceiver<ServiceEvent>)> {
|
||||||
|
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<ServiceState> {
|
||||||
|
self.rpc(SvmRequest::Status, id.service_id()).await?
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list(&mut self) -> SvcResult<Vec<(Uuid, ServiceName)>> {
|
||||||
|
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(&"<service>").finish(),
|
||||||
|
Self::RegisterTemplate(_arg0) => f
|
||||||
|
.debug_tuple("RegisterTemplate")
|
||||||
|
.field(&"<service>")
|
||||||
|
.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<SvmRequest>,
|
||||||
|
control_tx: mpsc::UnboundedSender<SvmRequest>,
|
||||||
|
service_rx: mpsc::UnboundedReceiver<ServiceEvent>,
|
||||||
|
service_tx: mpsc::UnboundedSender<ServiceEvent>,
|
||||||
|
subscribers: BTreeMap<Uuid, mpsc::UnboundedSender<ServiceEvent>>,
|
||||||
|
svcs: BTreeMap<Uuid, ServiceInstance>,
|
||||||
|
names: BTreeMap<ServiceName, Uuid>,
|
||||||
|
tasks: JoinSet<Result<(), RunSvcError>>,
|
||||||
|
templates: BTreeMap<String, Box<dyn ServiceTemplate>>,
|
||||||
|
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<SvcResult<()>>) {
|
||||||
|
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<SvcResult<()>>) {
|
||||||
|
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<SvmRequest> {
|
||||||
|
self.control_tx.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register(&mut self, name: ServiceName, svc: ServiceFunc) -> SvcResult<Uuid> {
|
||||||
|
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<Item = &Uuid> {
|
||||||
|
self.svcs.keys()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve(&self, handle: impl IntoServiceId) -> SvcResult<Uuid> {
|
||||||
|
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<Uuid> {
|
||||||
|
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<Uuid> {
|
||||||
|
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<Uuid> = 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<BTreeSet<Uuid>> {
|
||||||
|
let res = BTreeSet::from_iter(
|
||||||
|
handles
|
||||||
|
.iter()
|
||||||
|
.map(|id| self.resolve(id))
|
||||||
|
.collect::<Result<Vec<Uuid>, 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
71
crates/svc/src/policy.rs
Normal file
71
crates/svc/src/policy.rs
Normal file
@@ -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<Duration>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
crates/svc/src/rpc.rs
Normal file
34
crates/svc/src/rpc.rs
Normal file
@@ -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<Q, A> {
|
||||||
|
data: Q,
|
||||||
|
rsp: Sender<A>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Q, A> RpcRequest<Q, A> {
|
||||||
|
pub fn new(data: Q) -> (Self, Receiver<A>) {
|
||||||
|
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<A>) {
|
||||||
|
(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
260
crates/svc/src/runservice.rs
Normal file
260
crates/svc/src/runservice.rs
Normal file
@@ -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<ServiceEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl State {
|
||||||
|
pub const fn new(
|
||||||
|
id: Uuid,
|
||||||
|
state: ServiceState,
|
||||||
|
tx: mpsc::UnboundedSender<ServiceEvent>,
|
||||||
|
) -> 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<S: Service> {
|
||||||
|
name: String,
|
||||||
|
svc: S,
|
||||||
|
configure_policy: Policy,
|
||||||
|
start_policy: Policy,
|
||||||
|
run_policy: Policy,
|
||||||
|
stop_policy: Policy,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S: Service> StandardService<S> {
|
||||||
|
pub fn new(name: impl AsRef<str>, 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<S: Service + 'static> StandardService<S> {
|
||||||
|
pub fn boxed(self) -> ServiceFunc {
|
||||||
|
Box::new(|a, b, c| self.run(a, b, c))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
|
#[async_trait]
|
||||||
|
impl<S: Service> ServiceRunner for StandardService<S> {
|
||||||
|
async fn run(
|
||||||
|
mut self,
|
||||||
|
id: Uuid,
|
||||||
|
mut rx: watch::Receiver<ServiceState>,
|
||||||
|
tx: mpsc::UnboundedSender<ServiceEvent>,
|
||||||
|
) -> 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)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
153
crates/svc/src/serviceid.rs
Normal file
153
crates/svc/src/serviceid.rs
Normal file
@@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ServiceName> for String {
|
||||||
|
fn from(value: ServiceName) -> Self {
|
||||||
|
value.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServiceName {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(name: String, instance: Option<String>) -> 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<String>, instance: impl Into<String>) -> 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<I: IntoServiceId> 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<Uuid> for ServiceId {
|
||||||
|
fn from(value: Uuid) -> Self {
|
||||||
|
Self::Id(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<String> for ServiceId {
|
||||||
|
fn from(value: String) -> Self {
|
||||||
|
value.service_id()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<String> 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
70
crates/svc/src/template.rs
Normal file
70
crates/svc/src/template.rs
Normal file
@@ -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<BoxDynService, SvcError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ErrorAdapter<S: Service> {
|
||||||
|
svc: S,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S: Service> ErrorAdapter<S> {
|
||||||
|
pub const fn new(svc: S) -> Self {
|
||||||
|
Self { svc }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<S: Service> Service for ErrorAdapter<S> {
|
||||||
|
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<StopResult, Self::Error> {
|
||||||
|
self.svc
|
||||||
|
.signal_stop()
|
||||||
|
.await
|
||||||
|
.map_err(|err| RunSvcError::ServiceError(Box::new(err)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<F> ServiceTemplate for F
|
||||||
|
where
|
||||||
|
F: Fn(String) -> Result<BoxDynService, SvcError> + Send,
|
||||||
|
{
|
||||||
|
fn generate(&self, instance: String) -> Result<BoxDynService, SvcError> {
|
||||||
|
self(instance)
|
||||||
|
}
|
||||||
|
}
|
||||||
162
crates/svc/src/traits.rs
Normal file
162
crates/svc/src/traits.rs
Normal file
@@ -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<StopResult, Self::Error> {
|
||||||
|
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<dyn Service<Error = RunSvcError> + 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<StopResult, Self::Error>> {
|
||||||
|
(**self).signal_stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "manager")]
|
||||||
|
#[async_trait]
|
||||||
|
pub trait ServiceRunner {
|
||||||
|
async fn run(
|
||||||
|
mut self,
|
||||||
|
id: Uuid,
|
||||||
|
rx: watch::Receiver<ServiceState>,
|
||||||
|
tx: mpsc::UnboundedSender<ServiceEvent>,
|
||||||
|
) -> Result<(), RunSvcError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "manager")]
|
||||||
|
#[async_trait]
|
||||||
|
impl<E, F> Service for F
|
||||||
|
where
|
||||||
|
E: Error + Send + 'static,
|
||||||
|
F: Future<Output = Result<(), E>> + Send + Unpin,
|
||||||
|
{
|
||||||
|
type Error = E;
|
||||||
|
|
||||||
|
async fn run(&mut self) -> Result<(), E> {
|
||||||
|
self.await
|
||||||
|
}
|
||||||
|
}
|
||||||
22
crates/z2m/Cargo.toml
Normal file
22
crates/z2m/Cargo.toml
Normal file
@@ -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"
|
||||||
730
crates/z2m/src/api.rs
Normal file
730
crates/z2m/src/api.rs
Normal file
@@ -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<BridgeInfo>),
|
||||||
|
|
||||||
|
#[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<GroupAdd>),
|
||||||
|
|
||||||
|
#[serde(rename = "bridge/response/group/remove")]
|
||||||
|
BridgeResponseGroupRemove(Response<GroupRemove>),
|
||||||
|
|
||||||
|
#[serde(rename = "bridge/response/group/rename")]
|
||||||
|
BridgeResponseGroupRename(Response<GroupRename>),
|
||||||
|
|
||||||
|
#[serde(rename = "bridge/response/group/options")]
|
||||||
|
BridgeResponseGroupOptions(Response<GroupOptions>),
|
||||||
|
|
||||||
|
#[serde(rename = "bridge/response/group/members/add")]
|
||||||
|
BridgeGroupMembersAdd(Response<GroupMemberChange>),
|
||||||
|
|
||||||
|
#[serde(rename = "bridge/response/group/members/remove")]
|
||||||
|
BridgeGroupMembersRemove(Response<GroupMemberChange>),
|
||||||
|
|
||||||
|
#[serde(rename = "bridge/response/device/remove")]
|
||||||
|
BridgeDeviceRemove(Response<DeviceRemoveResponse>),
|
||||||
|
|
||||||
|
#[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<Endpoint>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub skip_disable_reporting: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct PermitJoin {
|
||||||
|
pub time: u32,
|
||||||
|
pub device: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<T> {
|
||||||
|
Ok {
|
||||||
|
data: T,
|
||||||
|
#[serde(default)]
|
||||||
|
transaction: Option<Value>,
|
||||||
|
},
|
||||||
|
Error {
|
||||||
|
error: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
transaction: Option<Value>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct GroupAdd {
|
||||||
|
pub id: Option<u32>,
|
||||||
|
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<u64, D::Error>
|
||||||
|
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: <zigbee2mqtt>/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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
type BridgeGroups = Vec<Group>;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct Group {
|
||||||
|
pub friendly_name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub id: u32,
|
||||||
|
pub members: Vec<GroupMember>,
|
||||||
|
pub scenes: Vec<Scene>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
pub properties: Value,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub config_type: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
pub advanced: ConfigAdvanced,
|
||||||
|
#[serde(default)]
|
||||||
|
pub availability: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
pub version: Value,
|
||||||
|
pub blocklist: Vec<Option<Value>>,
|
||||||
|
pub device_options: Value,
|
||||||
|
pub devices: HashMap<String, Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub external_converters: Vec<Option<Value>>,
|
||||||
|
pub frontend: Value,
|
||||||
|
pub groups: HashMap<String, GroupValue>,
|
||||||
|
#[serde(with = "crate::serde_util::struct_or_false")]
|
||||||
|
pub homeassistant: Option<ConfigHomeassistant>,
|
||||||
|
pub map_options: Value,
|
||||||
|
pub mqtt: Value,
|
||||||
|
pub ota: Value,
|
||||||
|
pub passlist: Vec<Option<Value>>,
|
||||||
|
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<Value>,
|
||||||
|
pub adapter_delay: Option<Value>,
|
||||||
|
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<i64>,
|
||||||
|
pub homeassistant_legacy_entity_attributes: Option<bool>,
|
||||||
|
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<String>,
|
||||||
|
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<String>,
|
||||||
|
pub disable_led: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub port: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ConfigHomeassistant {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub enabled: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub experimental_event_entities: Option<Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub legacy_action_sensor: Option<Value>,
|
||||||
|
pub discovery_topic: String,
|
||||||
|
pub status_topic: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct GroupValue {
|
||||||
|
#[serde(default)]
|
||||||
|
pub devices: Vec<String>,
|
||||||
|
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<Device>;
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
pub date_code: Option<String>,
|
||||||
|
pub definition: Option<DeviceDefinition>,
|
||||||
|
pub disabled: bool,
|
||||||
|
pub endpoints: HashMap<String, DeviceEndpoint>,
|
||||||
|
pub friendly_name: String,
|
||||||
|
pub ieee_address: IeeeAddress,
|
||||||
|
pub interview_completed: bool,
|
||||||
|
pub interviewing: bool,
|
||||||
|
pub manufacturer: Option<String>,
|
||||||
|
pub model_id: Option<String>,
|
||||||
|
pub network_address: u16,
|
||||||
|
#[serde(default)]
|
||||||
|
pub power_source: PowerSource,
|
||||||
|
pub software_build_id: Option<String>,
|
||||||
|
pub supported: Option<bool>,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub device_type: DeviceType,
|
||||||
|
|
||||||
|
/* all other fields */
|
||||||
|
#[serde(skip_serializing_if = "HashMap::is_empty")]
|
||||||
|
#[serde(default, flatten)]
|
||||||
|
pub __: HashMap<String, Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Expose>,
|
||||||
|
pub supports_ota: bool,
|
||||||
|
pub options: Vec<Expose>,
|
||||||
|
#[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<String, Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
pub label: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub access: u8,
|
||||||
|
pub endpoint: Option<String>,
|
||||||
|
pub property: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub features: Vec<Expose>,
|
||||||
|
pub category: Option<ExposeCategory>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Expose>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub length_min: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub length_max: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ExposeNumeric {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub base: ExposeBase,
|
||||||
|
|
||||||
|
pub unit: Option<String>,
|
||||||
|
pub value_max: Option<f64>,
|
||||||
|
pub value_min: Option<f64>,
|
||||||
|
pub value_step: Option<f64>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub presets: Vec<Preset>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ExposeSwitch {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub base: ExposeBase,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DeviceEndpoint {
|
||||||
|
pub bindings: Vec<DeviceEndpointBinding>,
|
||||||
|
pub configured_reportings: Vec<DeviceEndpointConfiguredReporting>,
|
||||||
|
pub clusters: DeviceEndpointClusters,
|
||||||
|
pub scenes: Vec<Scene>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
pub output: Vec<String>,
|
||||||
|
}
|
||||||
199
crates/z2m/src/convert.rs
Normal file
199
crates/z2m/src/convert.rs
Normal file
@@ -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<MirekSchema>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExtractExposeNumeric for ExposeNumeric {
|
||||||
|
#[must_use]
|
||||||
|
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
|
||||||
|
fn extract_mirek_schema(&self) -> Option<MirekSchema> {
|
||||||
|
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<Self>
|
||||||
|
where
|
||||||
|
Self: Sized;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExtractLightColor for LightColor {
|
||||||
|
fn extract_from_expose(expose: &Expose) -> Option<Self> {
|
||||||
|
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<Self>
|
||||||
|
where
|
||||||
|
Self: Sized;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExtractLightGradient for LightGradient {
|
||||||
|
#[must_use]
|
||||||
|
fn extract_from_expose(expose: &ExposeList) -> Option<Self> {
|
||||||
|
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<Self>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExtractColorTemperature for ColorTemperature {
|
||||||
|
#[must_use]
|
||||||
|
fn extract_from_expose(expose: &Expose) -> Option<Self> {
|
||||||
|
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<Self>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExtractDimming for Dimming {
|
||||||
|
#[must_use]
|
||||||
|
fn extract_from_expose(expose: &Expose) -> Option<Self> {
|
||||||
|
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("<unknown>", |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)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
25
crates/z2m/src/error.rs
Normal file
25
crates/z2m/src/error.rs
Normal file
@@ -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<T> = Result<T, Z2mError>;
|
||||||
107
crates/z2m/src/hexcolor.rs
Normal file
107
crates/z2m/src/hexcolor.rs
Normal file
@@ -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<HexColor> 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<Self, Self::Error> {
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
7
crates/z2m/src/lib.rs
Normal file
7
crates/z2m/src/lib.rs
Normal file
@@ -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;
|
||||||
57
crates/z2m/src/request.rs
Normal file
57
crates/z2m/src/request.rs
Normal file
@@ -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<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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),
|
||||||
|
}
|
||||||
130
crates/z2m/src/serde_util.rs
Normal file
130
crates/z2m/src/serde_util.rs
Normal file
@@ -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<Option<T>, D::Error>
|
||||||
|
where
|
||||||
|
T: Deserialize<'de>,
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
// Internal wrapper struct
|
||||||
|
struct StructOrFalse<T>(PhantomData<T>);
|
||||||
|
|
||||||
|
impl<'de, T> de::Visitor<'de> for StructOrFalse<T>
|
||||||
|
where
|
||||||
|
T: Deserialize<'de>,
|
||||||
|
{
|
||||||
|
type Value = Option<T>;
|
||||||
|
|
||||||
|
fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E>
|
||||||
|
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<M>(self, visitor: M) -> Result<Self::Value, M::Error>
|
||||||
|
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::<T>())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
d.deserialize_any(StructOrFalse(PhantomData))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn serialize_struct_or_false<T, S>(v: &Option<T>, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
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<Bar>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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>(FOO_NONE_STR)?, FOO_NONE);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
pub fn deserialize_struct() -> Z2mResult<()> {
|
||||||
|
assert_eq!(from_str::<Foo>(FOO_SOME_STR)?, FOO_SOME);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
pub fn deserialize_true() {
|
||||||
|
/* must return error */
|
||||||
|
from_str::<Foo>(FOO_TRUE).unwrap_err();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
pub fn deserialize_list() {
|
||||||
|
/* must return error */
|
||||||
|
from_str::<Foo>(FOO_LIST).unwrap_err();
|
||||||
|
}
|
||||||
|
}
|
||||||
257
crates/z2m/src/update.rs
Normal file
257
crates/z2m/src/update.rs
Normal file
@@ -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<DeviceState>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub brightness: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub color_temp: Option<u16>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub color_mode: Option<DeviceColorMode>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub color: Option<DeviceColor>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub gradient: Option<Vec<HexColor>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub linkquality: Option<u8>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub color_options: Option<ColorOptions>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub color_temp_startup: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub level_config: Option<LevelConfig>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[serde(default)]
|
||||||
|
pub elapsed: Option<u64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[serde(default)]
|
||||||
|
pub power_on_behavior: Option<PowerOnBehavior>,
|
||||||
|
#[serde(skip_serializing_if = "HashMap::is_empty")]
|
||||||
|
#[serde(default)]
|
||||||
|
pub update: HashMap<String, Value>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub update_available: Option<bool>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub battery: Option<Value>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub transition: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub effect: Option<DeviceEffect>,
|
||||||
|
|
||||||
|
/* all other fields */
|
||||||
|
#[serde(skip_serializing_if = "HashMap::is_empty")]
|
||||||
|
#[serde(default, flatten)]
|
||||||
|
pub __: HashMap<String, Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeviceUpdate {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_state(self, state: Option<bool>) -> Self {
|
||||||
|
Self {
|
||||||
|
state: state.map(|on| {
|
||||||
|
if on {
|
||||||
|
DeviceState::On
|
||||||
|
} else {
|
||||||
|
DeviceState::Off
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
..self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_brightness(self, brightness: Option<f64>) -> Self {
|
||||||
|
Self {
|
||||||
|
brightness: brightness.map(|b| b.clamp(1.0, 254.0)),
|
||||||
|
..self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_color_temp(self, mirek: Option<u16>) -> Self {
|
||||||
|
Self {
|
||||||
|
color_temp: mirek,
|
||||||
|
..self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_color_xy(self, xy: Option<XY>) -> Self {
|
||||||
|
Self {
|
||||||
|
color: xy.map(DeviceColor::xy),
|
||||||
|
..self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_gradient(self, grad: Option<LightGradientUpdate>) -> 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<f64>) -> 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<f64>,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
s: Option<f64>,
|
||||||
|
|
||||||
|
pub hue: Option<f64>,
|
||||||
|
pub saturation: Option<f64>,
|
||||||
|
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub xy: Option<XY>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<bool>,
|
||||||
|
pub on_off_transition_time: Option<u16>,
|
||||||
|
pub on_transition_time: Option<u16>,
|
||||||
|
pub off_transition_time: Option<u16>,
|
||||||
|
pub current_level_startup: Option<CurrentLevelStartup>,
|
||||||
|
pub on_level: Option<OnLevel>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<DeviceState> 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,
|
||||||
|
}
|
||||||
23
crates/zcl/Cargo.toml
Normal file
23
crates/zcl/Cargo.toml
Normal file
@@ -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"
|
||||||
351
crates/zcl/src/attr.rs
Normal file
351
crates/zcl/src/attr.rs
Normal file
@@ -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<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ZclReadAttr {
|
||||||
|
pub fn parse(data: &[u8]) -> ZclResult<Self> {
|
||||||
|
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<u8>),
|
||||||
|
String(String),
|
||||||
|
IeeeAddr(Vec<u8>),
|
||||||
|
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<Self> {
|
||||||
|
let key = rdr.read_u16::<LE>()?;
|
||||||
|
|
||||||
|
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::<LE>()?),
|
||||||
|
ZclDataType::Zcl32bit => ZclAttrValue::X32(rdr.read_i32::<LE>()?),
|
||||||
|
ZclDataType::ZclBool => ZclAttrValue::Bool(rdr.read_u8()? != 0),
|
||||||
|
ZclDataType::Zcl8bitmap => ZclAttrValue::B8(rdr.read_u8()?),
|
||||||
|
ZclDataType::Zcl16bitmap => ZclAttrValue::B16(rdr.read_u16::<LE>()?),
|
||||||
|
ZclDataType::Zcl32bitmap => ZclAttrValue::B32(rdr.read_u32::<LE>()?),
|
||||||
|
ZclDataType::Zcl40bitmap => todo!(),
|
||||||
|
ZclDataType::Zcl48bitmap => todo!(),
|
||||||
|
ZclDataType::Zcl56bitmap => todo!(),
|
||||||
|
ZclDataType::Zcl64bitmap => ZclAttrValue::B64(rdr.read_u64::<LE>()?),
|
||||||
|
ZclDataType::ZclU8 => ZclAttrValue::U8(rdr.read_u8()?),
|
||||||
|
ZclDataType::ZclU16 => ZclAttrValue::U16(rdr.read_u16::<LE>()?),
|
||||||
|
ZclDataType::ZclU32 => ZclAttrValue::U32(rdr.read_u32::<LE>()?),
|
||||||
|
ZclDataType::ZclI16 => ZclAttrValue::I16(rdr.read_i16::<LE>()?),
|
||||||
|
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> {
|
||||||
|
Self::from_reader(rdr, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn writeattr_from_reader(rdr: &mut impl Read) -> ZclResult<Self> {
|
||||||
|
Self::from_reader(rdr, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ZclReadAttrResp {
|
||||||
|
pub attr: Vec<ZclAttr>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ZclReadAttrResp {
|
||||||
|
#[allow(clippy::cast_possible_truncation)]
|
||||||
|
pub fn parse(data: &[u8]) -> ZclResult<Self> {
|
||||||
|
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<ZclAttr>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ZclWriteAttr {
|
||||||
|
#[allow(clippy::cast_possible_truncation)]
|
||||||
|
pub fn parse(data: &[u8]) -> ZclResult<Self> {
|
||||||
|
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<ZclAttr>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ZclReportAttr {
|
||||||
|
#[allow(clippy::cast_possible_truncation)]
|
||||||
|
pub fn parse(data: &[u8]) -> ZclResult<Self> {
|
||||||
|
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<Self> {
|
||||||
|
Ok(Self {
|
||||||
|
cmd: data[0],
|
||||||
|
stat: data[1],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ZclWriteAttrResp {
|
||||||
|
pub attr: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ZclWriteAttrResp {
|
||||||
|
pub fn parse(data: &[u8]) -> ZclResult<Self> {
|
||||||
|
Ok(Self {
|
||||||
|
attr: data.to_vec(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
35
crates/zcl/src/cluster/colorctrl.rs
Normal file
35
crates/zcl/src/cluster/colorctrl.rs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
use crate::frame::{ZclFrame, ZclFrameDirection};
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn describe(frame: &ZclFrame, _data: &[u8]) -> Option<String> {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
20
crates/zcl/src/cluster/commissioning.rs
Normal file
20
crates/zcl/src/cluster/commissioning.rs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
use crate::error::ZclResult;
|
||||||
|
use crate::frame::ZclFrame;
|
||||||
|
use hue::zigbee::HueEntFrame;
|
||||||
|
|
||||||
|
pub fn describe(frame: &ZclFrame, data: &[u8]) -> ZclResult<Option<String>> {
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
13
crates/zcl/src/cluster/effects.rs
Normal file
13
crates/zcl/src/cluster/effects.rs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
use crate::frame::{ZclFrame, ZclFrameDirection};
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn describe(frame: &ZclFrame, _data: &[u8]) -> Option<String> {
|
||||||
|
if frame.flags.direction == ZclFrameDirection::ClientToServer {
|
||||||
|
match frame.cmd {
|
||||||
|
0x40 => Some("Trigger".to_string()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
18
crates/zcl/src/cluster/groups.rs
Normal file
18
crates/zcl/src/cluster/groups.rs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
use crate::frame::{ZclFrame, ZclFrameDirection};
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn describe(frame: &ZclFrame, _data: &[u8]) -> Option<String> {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
crates/zcl/src/cluster/hue_fc01.rs
Normal file
26
crates/zcl/src/cluster/hue_fc01.rs
Normal file
@@ -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<Option<String>> {
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
18
crates/zcl/src/cluster/hue_fc03.rs
Normal file
18
crates/zcl/src/cluster/hue_fc03.rs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
use hue::zigbee::Flags;
|
||||||
|
|
||||||
|
use crate::error::ZclResult;
|
||||||
|
use crate::frame::ZclFrame;
|
||||||
|
|
||||||
|
pub fn describe(frame: &ZclFrame, data: &[u8]) -> ZclResult<Option<String>> {
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
24
crates/zcl/src/cluster/levelctrl.rs
Normal file
24
crates/zcl/src/cluster/levelctrl.rs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
use crate::frame::{ZclFrame, ZclFrameDirection};
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn describe(frame: &ZclFrame, _data: &[u8]) -> Option<String> {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
10
crates/zcl/src/cluster/mod.rs
Normal file
10
crates/zcl/src/cluster/mod.rs
Normal file
@@ -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;
|
||||||
19
crates/zcl/src/cluster/onoff.rs
Normal file
19
crates/zcl/src/cluster/onoff.rs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
use crate::frame::{ZclFrame, ZclFrameDirection};
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn describe(frame: &ZclFrame, _data: &[u8]) -> Option<String> {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
39
crates/zcl/src/cluster/scenes.rs
Normal file
39
crates/zcl/src/cluster/scenes.rs
Normal file
@@ -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<String> {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
41
crates/zcl/src/cluster/standard.rs
Normal file
41
crates/zcl/src/cluster/standard.rs
Normal file
@@ -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<Option<String>> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
22
crates/zcl/src/error.rs
Normal file
22
crates/zcl/src/error.rs
Normal file
@@ -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<T> = Result<T, ZclError>;
|
||||||
100
crates/zcl/src/frame.rs
Normal file
100
crates/zcl/src/frame.rs
Normal file
@@ -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<u16>,
|
||||||
|
pub seqnr: u8,
|
||||||
|
pub cmd: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ZclFrame {
|
||||||
|
pub fn parse(data: &mut impl Read) -> ZclResult<Self> {
|
||||||
|
let flags = ZclFrameFlags::unpack(&[data.read_u8()?])?;
|
||||||
|
|
||||||
|
let mfcode = if flags.manufacturer_specific {
|
||||||
|
Some(data.read_u16::<BE>()?)
|
||||||
|
} 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
|
||||||
|
}
|
||||||
|
}
|
||||||
4
crates/zcl/src/lib.rs
Normal file
4
crates/zcl/src/lib.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod attr;
|
||||||
|
pub mod cluster;
|
||||||
|
pub mod error;
|
||||||
|
pub mod frame;
|
||||||
36
doc/bifrost.service.ex
Normal file
36
doc/bifrost.service.ex
Normal file
@@ -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
|
||||||
69
doc/comparison-with-diyhue.md
Normal file
69
doc/comparison-with-diyhue.md
Normal file
@@ -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
|
||||||
178
doc/config-reference.md
Normal file
178
doc/config-reference.md
Normal file
@@ -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=<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
|
||||||
|
|
||||||
|
...
|
||||||
|
```
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user