-
Notifications
You must be signed in to change notification settings - Fork 1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: discovery document macro #19
Conversation
@arcnmx before I steam-roll my way to a destination I figured I'd ask if you had any input on this. The proof of concept macro for creating the entity types is starting to take shape. It currently does the following:
In addition to this, the fields on the entity structs have been made private (so that if you have a Example: use hass_mqtt_discovery::{
availability::Availability, availability::AvailabilityMode, device::Device,
device_class::DeviceClass, entity_category::EntityCategory, icon::Icon, name::Name,
payload::Payload, qos::MqttQoS, template::Template, topic::Topic, unique_id::UniqueId,
};
use hass_mqtt_discovery_macros::entity_document;
use std::borrow::Cow;
/// The mqtt switch platform lets you control your MQTT enabled switches.
///
/// See: <https://www.home-assistant.io/integrations/switch.mqtt/>
#[entity_document]
pub struct Switch<'a> {
/// The MQTT topic to publish commands to change the switch state.
#[entity(validate)]
#[serde(borrow)]
command_topic: Topic<'a>,
/// The [type/class][device_class] of the switch to set the icon in the frontend.
///
/// [device_class]: https://www.home-assistant.io/integrations/switch/#device-class
#[serde(default, skip_serializing_if = "Option::is_none")]
device_class: Option<DeviceClass>,
/// Flag that defines if switch works in optimistic mode.
/// Defaults to `true` if no `state_topic` defined, else `false`.
#[serde(default, skip_serializing_if = "Option::is_none")]
optimistic: Option<bool>,
/// The payload that represents `off` state. If specified, will be
/// used for both comparing to the value in the `state_topic` (see
/// `value_template` and `state_off` for details) and sending as
/// `off` command to the `command_topic`.
/// Defaults to `"OFF"`.
#[entity(validate)]
#[serde(borrow, default, skip_serializing_if = "Option::is_none")]
payload_off: Option<Payload<'a>>,
/// The payload that represents `on` state. If specified, will be
/// used for both comparing to the value in the `state_topic` (see
/// `value_template` and `state_on` for details) and sending as
/// `on` command to the `command_topic`.
/// Defaults to `"ON"`.
#[entity(validate)]
#[serde(borrow, default, skip_serializing_if = "Option::is_none")]
payload_on: Option<Payload<'a>>,
/// If the published message should have the retain flag on or not.
/// Defaults to `false`.
#[serde(default, skip_serializing_if = "Option::is_none")]
retain: Option<bool>,
/// The payload that represents the `off` state. Used when value that
/// represents `off` state in the `state_topic` is different from value that
/// should be sent to the `command_topic` to turn the device `off`.
/// Defaults to `payload_off` if defined, else `"OFF"`.
#[entity(validate)]
#[serde(borrow, default, skip_serializing_if = "Option::is_none")]
state_off: Option<Payload<'a>>,
/// The payload that represents the `on` state. Used when value that
/// represents on state in the `state_topic` is different from value that
/// should be sent to the `command_topic` to turn the device `on`.
/// Defaults to `payload_on` if defined, else `"ON"`.
#[entity(validate)]
#[serde(borrow, default, skip_serializing_if = "Option::is_none")]
state_on: Option<Payload<'a>>,
/// The MQTT topic subscribed to receive state updates.
#[entity(validate)]
#[serde(borrow, default, skip_serializing_if = "Option::is_none")]
state_topic: Option<Topic<'a>>,
/// Defines a [template][template] to extract device’s state from the
/// `state_topic`. To determine the switches’s state result of this
/// template will be compared to `state_on` and `state_off`.
///
/// [template]: https://www.home-assistant.io/docs/configuration/templating/#processing-incoming-data
#[entity(validate)]
#[serde(borrow, default, skip_serializing_if = "Option::is_none")]
value_template: Option<Template<'a>>,
} |
Also - what I was mainly going to ask about was method names for builder setters. I'm currently considering a couple: pub fn with_topic(self, topic: Topic<'a>) -> Self;
pub fn topic(&mut self) -> &mut Option<Topic<'a>>; or pub fn with_topic(self, topic: Topic<'a>) -> Self;
pub fn topic(&mut self, topic: Topic<'a>) -> &mut Self; |
I'll try to take a proper look in the next few days when I can, but wanted to mention...
I'm not really in favour of this, especially since a read-only wrapper type already exists for exactly this purpose: |
The main issue I have with this is that you end up with the builder being mostly useless - the moment you have a builder API, I'm in flavor of the builder being the scratch surface where you can have invalid data, and when you "build" the entity, it's guaranteed to be valid. If the builder allows you to create invalid data, I feel it's less useful. But then, giving you direct access to the fields makes it so you can re-add invalid data. What I've done instead is make it easy to convert back to a builder (where you can then modify things), and build that back into a new entity. |
My issue with this is that it just sounds like a complex reimplementation of Another advantage is that it's a standard way to do this provided by the validation library that's already in use -
well, builders have basically one purpose: to facilitate the construction of values that would be cumbersome to build otherwise; often (and in this case) due to data structures containing a large number of optional fields. Validating a data struct is already a single function call away ( |
I tend to try to follow the mantra of "make invalid state unrepresentable" - but in tihs case I've (after trying a lot) figured that I can't do it without loosing part of the point of the discovery library. The discovery library is supposed to be low-level and (hopefully) low overhead, with nicer APIs built on top - the issue with builder -> value paradigm is that if you have a list of builders that you want to build&validate, you need to allocate a list of results. Or do some really ugly schenanigans with mutable enums of valid and invalid data intermingled. That's one of the nice things about the semval::Validated - it just asserts the whole tree is validated without actually touching it. But I do loose the property that any Anyways - I think I've closing in on a design that will both work well, and I'm reasonably happy with. I currently have the below working. Note that the fields are back to being public - but there are accessor/modifier functions generated with the same names. Basically, I made the actual entity-documents into the builder - so we only have builders and I'm also contemplating getting rid of all the pub trait Switch {
fn as_document(&self) -> discovery::Switch;
} and will instead have to do something like a visitor pattern - but I think that's an ok compromise. use hass_mqtt_discovery::{
device_class::DeviceClass, payload::Payload, template::Template, topic::Topic,
};
use hass_mqtt_discovery_macros::entity_document;
use std::borrow::Cow;
/// The mqtt switch platform lets you control your MQTT enabled switches.
///
/// See: <https://www.home-assistant.io/integrations/switch.mqtt/>
#[entity_document]
pub struct Switch<'a> {
/// The MQTT topic to publish commands to change the switch state.
#[entity(validate)]
#[serde(borrow)]
pub command_topic: Topic<'a>,
/// The [type/class][device_class] of the switch to set the icon in the frontend.
///
/// [device_class]: https://www.home-assistant.io/integrations/switch/#device-class
#[serde(default, skip_serializing_if = "Option::is_none")]
pub device_class: Option<DeviceClass>,
/// Flag that defines if switch works in optimistic mode.
/// Defaults to `true` if no `state_topic` defined, else `false`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub optimistic: Option<bool>,
/// The payload that represents `off` state. If specified, will be
/// used for both comparing to the value in the `state_topic` (see
/// `value_template` and `state_off` for details) and sending as
/// `off` command to the `command_topic`.
/// Defaults to `"OFF"`.
#[entity(validate)]
#[serde(borrow, default, skip_serializing_if = "Option::is_none")]
pub payload_off: Option<Payload<'a>>,
/// The payload that represents `on` state. If specified, will be
/// used for both comparing to the value in the `state_topic` (see
/// `value_template` and `state_on` for details) and sending as
/// `on` command to the `command_topic`.
/// Defaults to `"ON"`.
#[entity(validate)]
#[serde(borrow, default, skip_serializing_if = "Option::is_none")]
pub payload_on: Option<Payload<'a>>,
/// If the published message should have the retain flag on or not.
/// Defaults to `false`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retain: Option<bool>,
/// The payload that represents the `off` state. Used when value that
/// represents `off` state in the `state_topic` is different from value that
/// should be sent to the `command_topic` to turn the device `off`.
/// Defaults to `payload_off` if defined, else `"OFF"`.
#[entity(validate)]
#[serde(borrow, default, skip_serializing_if = "Option::is_none")]
pub state_off: Option<Payload<'a>>,
/// The payload that represents the `on` state. Used when value that
/// represents on state in the `state_topic` is different from value that
/// should be sent to the `command_topic` to turn the device `on`.
/// Defaults to `payload_on` if defined, else `"ON"`.
#[entity(validate)]
#[serde(borrow, default, skip_serializing_if = "Option::is_none")]
pub state_on: Option<Payload<'a>>,
/// The MQTT topic subscribed to receive state updates.
#[entity(validate)]
#[serde(borrow, default, skip_serializing_if = "Option::is_none")]
pub state_topic: Option<Topic<'a>>,
/// Defines a [template][template] to extract device’s state from the
/// `state_topic`. To determine the switches’s state result of this
/// template will be compared to `state_on` and `state_off`.
///
/// [template]: https://www.home-assistant.io/docs/configuration/templating/#processing-incoming-data
#[entity(validate)]
#[serde(borrow, default, skip_serializing_if = "Option::is_none")]
pub value_template: Option<Template<'a>>,
}
// pub enum SwitchInvalidity {
// Availability(<Cow<'static, [Availability<'static>]> as ::semval::Validate>::Invalidity),
// Device(<Option<Device<'static>> as ::semval::Validate>::Invalidity),
// Icon(<Option<Icon<'static>> as ::semval::Validate>::Invalidity),
// JsonAttributesTemplate(<Option<Template<'static>> as ::semval::Validate>::Invalidity),
// JsonAttributesTopic(<Option<Topic<'static>> as ::semval::Validate>::Invalidity),
// Name(<Option<Name<'static>> as ::semval::Validate>::Invalidity),
// UniqueId(<Option<UniqueId<'static>> as ::semval::Validate>::Invalidity),
// }
fn main() {
let switch = Switch::new("switch/foo")
.optimistic(true)
.state_on("ON")
.state_off("OFF")
.state_topic("switch/foo/state");
println!("{}", serde_json::to_string_pretty(&switch).unwrap());
} |
(I will take an actual look at the code soon - example definition looks nice though!)
I do agree and aim for this typically, but it can depend on abstraction levels/boundaries? At some point, you are just working with a basic struct layout that just represents the json directly, and I'm not often a fan of hiding that behind private types - usually because it means that the crate itself is privileged in what it can accomplish, and downstream crates don't have as much flexibility. But there's always a balance between that and not exposing irrelevant implementation details...
It's too bad that
Seems a tad excessive (higher-level code is likely to already be dealing with |
Deserialize is a reason to keep the cows... I'll have to think about this a bit. That being said - I've shifted my thinking of the use of this library slightly. So let's imagine you have a impl AsDiscoveryDocument for MySwitch {
fn as_discovery_document<'a>(&'a self) -> Switch<'a> {
// make cows
// return cows
}
} Instead you'd do something like this: impl DiscoveryDocument for MySwitch {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let doc = Switch::new(self.blah).topic(format!("blah")); // short lived - doesn't need cows
doc.serialize(serializer)
}
} |
To be fair you don't really have to make them yourself if the
That would actually need to be: let topic = format!("blah");
let doc = Switch::new(self.blah).topic(&topic);
// or all in one expression so the temporary string lives only as long as the doc:
Switch::new(self.blah).topic(&format!("blah")).serialize() But that's really still just an inline/private implementation of impl AsDiscoveryDocument for MySwitch {
fn process_discovery_document<R, for <'a> F: FnOnce(Switch<'a>) -> R>(&self, f: F) -> R {
let topic = format!("blah");
let doc = Switch::new(self.blah).topic(&topic);
f(doc)
}
}
impl DiscoveryDocument for MySwitch {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.process_discovery_document(|doc| doc.serialize(serializer))
}
} I'm not even disagreeing btw, just exploring the design space. Types with lifetimes that can't be |
I've added It also had a custom validation rule which forced me to figure out how to extend the validation the macro generates. I'm pretty happy with the system as it stands having tested it a bit - and unless you have any major worries I will probably be merging this as is (keeping the Cows). It's not like it can't be changed later anyways. I wanna try start building a layer on top of the discovery crate - and that might mean looking at more macros and maybe even some specialization :D |
So I'm trying to use it but my builds are failing, I think because cargo workspace dependencies are too new of a feature? 😞 EDIT: confirmed, removing them fixes my problem. It was failing for me even when I use 1.64.0 (which is supposed to support them) because the build system vendors dependencies and apparently doesn't support it... |
Which build system is it you're using that's not supporting this? |
nix and nixpkgs. I've created an example reproducing the problem and reported it, basically anything that depends on a package that uses workspace dependencies fails because when they get vendored as part of the build, the package presumably gets plucked out of the workspace and can no longer find the versions. |
I'd argue that's more of a "people who aren't using the official rust tools will have issues" problem - but on the other hand that's apparently also 100% of the userbase of this library :P. I'll revert to not using workspace packages and then probably merge this soon. I've made an issue for migrating to workspace packages: #21 - can you link the upstream issue there so it's easier to track? |
Yeah, sorry to be a pain... But to be fair, the feature was just released one version ago - if I weren't using the rolling unstable/unreleased version of NixOS I wouldn't even have a cargo version new enough to support it >< |
I tend to not use developer tools from my system package manager for that exact reason (even though I'm on arch linux :P) Also - I stole your commit that "fixed" the dependencies. I'll leave it to renovate to keep them in sync for now. |
BREAKING-CHANGE: fields are no longer public