first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 22:29:38 +03:00
commit 427856cd3a
176 changed files with 27613 additions and 0 deletions

22
crates/z2m/Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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),
}

View 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
View 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,
}