first commit
This commit is contained in:
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,
|
||||
}
|
||||
Reference in New Issue
Block a user