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

23
crates/zcl/Cargo.toml Normal file
View 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
View 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(),
})
}
}

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

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

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

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

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

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

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

View 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;

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

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

View 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
View 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
View 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
View File

@@ -0,0 +1,4 @@
pub mod attr;
pub mod cluster;
pub mod error;
pub mod frame;