e05f3d768f
- 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
296 lines
9.2 KiB
Rust
296 lines
9.2 KiB
Rust
//! 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)
|
|
}
|
|
}
|