Add mcutie MQTT client implementation and improve library structure
- Integrated `mcutie` library as a core MQTT client for device communication. - Added support for Home Assistant entities (binary sensor, button) via MQTT. - Implemented buffer management, async operations, and packet encoding/decoding. - Introduced structured error handling and device registration features. - Updated `Cargo.toml` with new dependencies and enabled feature flags for `serde` and `log`. - Enhanced logging macros with configurable options (`defmt` or `log`). - Organized codebase into modules (buffer, components, IO, publish, etc.) for better maintainability. fix legacy dependecencies and compatiblity with mcutie vendored lib fix shit i hate this
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
//! Home Assistant auto-discovery and related messages.
|
||||
//!
|
||||
//! Normally you would declare your entities statically in your binary. It is
|
||||
//! then trivial to send out discovery messages or state changes.
|
||||
//!
|
||||
//! ```
|
||||
//! # use mcutie::{Publishable, Topic};
|
||||
//! # use mcutie::homeassistant::{Entity, Device, Origin, AvailabilityState, AvailabilityTopics};
|
||||
//! # use mcutie::homeassistant::binary_sensor::{BinarySensor, BinarySensorClass, BinarySensorState};
|
||||
//! const DEVICE_AVAILABILITY_TOPIC: Topic<&'static str> = Topic::Device("status");
|
||||
//! const MOTION_STATE_TOPIC: Topic<&'static str> = Topic::Device("motion/status");
|
||||
//!
|
||||
//! const DEVICE: Device<'static> = Device::new();
|
||||
//! const ORIGIN: Origin<'static> = Origin::new();
|
||||
//!
|
||||
//! const MOTION_SENSOR: Entity<'static, 1, BinarySensor> = Entity {
|
||||
//! device: DEVICE,
|
||||
//! origin: ORIGIN,
|
||||
//! object_id: "motion",
|
||||
//! unique_id: Some("motion"),
|
||||
//! name: "Motion",
|
||||
//! availability: AvailabilityTopics::All([DEVICE_AVAILABILITY_TOPIC]),
|
||||
//! state_topic: Some(MOTION_STATE_TOPIC),
|
||||
//! command_topic: None,
|
||||
//! component: BinarySensor {
|
||||
//! device_class: Some(BinarySensorClass::Motion),
|
||||
//! },
|
||||
//! };
|
||||
//!
|
||||
//! async fn send_discovery_messages() {
|
||||
//! MOTION_SENSOR.publish_discovery().await.unwrap();
|
||||
//! DEVICE_AVAILABILITY_TOPIC.with_bytes(AvailabilityState::Online).publish().await.unwrap();
|
||||
//! }
|
||||
//!
|
||||
//! async fn send_state(state: BinarySensorState) {
|
||||
//! MOTION_SENSOR.publish_state(state).await.unwrap();
|
||||
//! }
|
||||
//! ```
|
||||
use core::{future::Future, ops::Deref};
|
||||
|
||||
use mqttrs::QoS;
|
||||
use serde::{
|
||||
ser::{Error as _, SerializeStruct},
|
||||
Serialize, Serializer,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
device_id, device_type, homeassistant::ser::DiscoverySerializer, io::publish, Error,
|
||||
McutieTask, MqttMessage, Payload, Publishable, Topic, TopicString, DATA_CHANNEL,
|
||||
};
|
||||
|
||||
pub mod binary_sensor;
|
||||
pub mod button;
|
||||
pub mod light;
|
||||
pub mod sensor;
|
||||
mod ser;
|
||||
|
||||
const HA_STATUS_TOPIC: Topic<&'static str> = Topic::General("homeassistant/status");
|
||||
const STATE_ONLINE: &str = "online";
|
||||
const STATE_OFFLINE: &str = "offline";
|
||||
|
||||
/// A trait representing a specific type of entity in Home Assistant
|
||||
pub trait Component: Serialize {
|
||||
/// The state to publish.
|
||||
type State;
|
||||
|
||||
/// The platform identifier for this entity. Internal.
|
||||
fn platform() -> &'static str;
|
||||
|
||||
/// Publishes this entity's state to the MQTT broker.
|
||||
fn publish_state<T: Deref<Target = str>>(
|
||||
&self,
|
||||
topic: &Topic<T>,
|
||||
state: Self::State,
|
||||
) -> impl Future<Output = Result<(), Error>>;
|
||||
}
|
||||
|
||||
impl<'t, T, L, const S: usize> McutieTask<'t, T, L, S>
|
||||
where
|
||||
T: Deref<Target = str> + 't,
|
||||
L: Publishable + 't,
|
||||
{
|
||||
pub(super) async fn ha_after_connected(&self) {
|
||||
let _ = HA_STATUS_TOPIC.subscribe(false).await;
|
||||
}
|
||||
|
||||
pub(super) async fn ha_handle_update(
|
||||
&self,
|
||||
topic: &Topic<TopicString>,
|
||||
payload: &Payload,
|
||||
) -> bool {
|
||||
if topic == &HA_STATUS_TOPIC {
|
||||
if payload.as_ref() == STATE_ONLINE.as_bytes() {
|
||||
DATA_CHANNEL.send(MqttMessage::HomeAssistantOnline).await;
|
||||
}
|
||||
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Deref<Target = str>> Serialize for Topic<T> {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
let mut topic = TopicString::new();
|
||||
self.to_string(&mut topic)
|
||||
.map_err(|_| S::Error::custom("topic was too large to serialize"))?;
|
||||
serializer.serialize_str(&topic)
|
||||
}
|
||||
}
|
||||
|
||||
fn name_or_device<S>(name: &Option<&str>, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(name.unwrap_or_else(|| device_type()))
|
||||
}
|
||||
|
||||
/// Represents the device in Home Assistant.
|
||||
///
|
||||
/// Can just be the default in which case useful properties such as the ID are
|
||||
/// automatically included.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct Device<'a> {
|
||||
/// A name to identify the device. If not provided the default device type is
|
||||
/// used.
|
||||
pub name: Option<&'a str>,
|
||||
/// An optional configuration URL for the device.
|
||||
pub configuration_url: Option<&'a str>,
|
||||
}
|
||||
|
||||
impl Device<'_> {
|
||||
/// Creates a new default device.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
name: None,
|
||||
configuration_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Device<'_> {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
let mut len = 2;
|
||||
if self.configuration_url.is_some() {
|
||||
len += 1;
|
||||
}
|
||||
|
||||
let mut serializer = serializer.serialize_struct("Device", len)?;
|
||||
|
||||
serializer.serialize_field("name", self.name.unwrap_or_else(|| device_type()))?;
|
||||
serializer.serialize_field("ids", device_id())?;
|
||||
|
||||
if let Some(cu) = self.configuration_url {
|
||||
serializer.serialize_field("cu", cu)?;
|
||||
} else {
|
||||
serializer.skip_field("cu")?;
|
||||
}
|
||||
|
||||
serializer.end()
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents the device's origin in Home Assistant.
|
||||
///
|
||||
/// Can just be the default in which case useful properties are automatically
|
||||
/// included.
|
||||
#[derive(Clone, Copy, Default, Serialize)]
|
||||
pub struct Origin<'a> {
|
||||
/// A name to identify the device's origin. If not provided the default
|
||||
/// device type is used.
|
||||
#[serde(serialize_with = "name_or_device")]
|
||||
pub name: Option<&'a str>,
|
||||
}
|
||||
|
||||
impl Origin<'_> {
|
||||
/// Creates a new default origin.
|
||||
pub const fn new() -> Self {
|
||||
Self { name: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// A single entity for Home Assistant.
|
||||
///
|
||||
/// Calling [`Entity::publish_discovery`] will publish the discovery message to
|
||||
/// allow Home Assistant to detect this entity. Read the
|
||||
/// [Home Assistant MQTT docs](https://www.home-assistant.io/integrations/mqtt/)
|
||||
/// for information on what some of these properties mean.
|
||||
pub struct Entity<'a, const A: usize, C: Component> {
|
||||
/// The device this entity is a part of.
|
||||
pub device: Device<'a>,
|
||||
/// The origin of the device.
|
||||
pub origin: Origin<'a>,
|
||||
/// An object identifier to allow for entity ID customisation in Home Assistant.
|
||||
pub object_id: &'a str,
|
||||
/// An optional unique identifier for the entity.
|
||||
pub unique_id: Option<&'a str>,
|
||||
/// A friendly name for the entity.
|
||||
pub name: &'a str,
|
||||
/// Specifies the availability topics that Home Assistant will listen to to
|
||||
/// determine this entity's availability.
|
||||
pub availability: AvailabilityTopics<'a, A>,
|
||||
/// The state topic that this entity's state is published to.
|
||||
pub state_topic: Option<Topic<&'a str>>,
|
||||
/// The command topic that this entity receives commands from.
|
||||
pub command_topic: Option<Topic<&'a str>>,
|
||||
/// The specific entity.
|
||||
pub component: C,
|
||||
}
|
||||
|
||||
impl<const A: usize, C: Component> Entity<'_, A, C> {
|
||||
/// Publishes the discovery message for this entity to the broker.
|
||||
pub async fn publish_discovery(&self) -> Result<(), Error> {
|
||||
let mut topic = TopicString::new();
|
||||
topic
|
||||
.push_str(option_env!("HA_DISCOVERY_PREFIX").unwrap_or("homeassistant"))
|
||||
.map_err(|_| Error::TooLarge)?;
|
||||
topic.push('/').map_err(|_| Error::TooLarge)?;
|
||||
topic.push_str(C::platform()).map_err(|_| Error::TooLarge)?;
|
||||
topic.push('/').map_err(|_| Error::TooLarge)?;
|
||||
topic
|
||||
.push_str(self.object_id)
|
||||
.map_err(|_| Error::TooLarge)?;
|
||||
topic.push_str("/config").map_err(|_| Error::TooLarge)?;
|
||||
|
||||
let mut payload = Payload::new();
|
||||
payload.serialize_json(self).map_err(|_| Error::TooLarge)?;
|
||||
|
||||
publish(&topic, &payload, QoS::AtMostOnce, false).await
|
||||
}
|
||||
|
||||
/// Publishes this entity's state to the broker.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - [`Error::Invalid`] if the entity doesn't have a state topic.
|
||||
pub async fn publish_state(&self, state: C::State) -> Result<(), Error> {
|
||||
if let Some(topic) = self.state_topic {
|
||||
self.component.publish_state(&topic, state).await
|
||||
} else {
|
||||
Err(Error::Invalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A payload representing a device or entity's availability.
|
||||
#[allow(missing_docs)]
|
||||
pub enum AvailabilityState {
|
||||
Online,
|
||||
Offline,
|
||||
}
|
||||
|
||||
impl AsRef<[u8]> for AvailabilityState {
|
||||
fn as_ref(&self) -> &'static [u8] {
|
||||
match self {
|
||||
Self::Online => STATE_ONLINE.as_bytes(),
|
||||
Self::Offline => STATE_OFFLINE.as_bytes(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The availiabity topics that home assistant will use to determine an entity's
|
||||
/// availability.
|
||||
pub enum AvailabilityTopics<'a, const A: usize> {
|
||||
/// The entity is always available.
|
||||
None,
|
||||
/// The entity is available if all of the topics are publishes as online.
|
||||
All([Topic<&'a str>; A]),
|
||||
/// The entity is available if any of the topics are publishes as online.
|
||||
Any([Topic<&'a str>; A]),
|
||||
/// The entity is available based on the most recent of the topics to
|
||||
/// publish state.
|
||||
Latest([Topic<&'a str>; A]),
|
||||
}
|
||||
|
||||
impl<const A: usize, C: Component> Serialize for Entity<'_, A, C> {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
let outer = DiscoverySerializer {
|
||||
discovery: self,
|
||||
inner: serializer,
|
||||
};
|
||||
|
||||
self.component.serialize(outer)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user