diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1998a795..9da72bf3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,36 +27,22 @@ jobs: command: fmt args: --all -- --check + - uses: actions-rs/clippy-check@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + - uses: actions/setup-python@v1 - name: Install Pylint run: | python -m pip install --upgrade pip python -m pip install pylint + - name: Run Pylint working-directory: py/miniconf-mqtt run: | python -m pip install . python -m pylint miniconf - - clippy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - - uses: actions-rs/toolchain@v1 - with: - profile: minimal - toolchain: stable - target: thumbv7em-none-eabihf - override: true - components: clippy - - - name: Clippy Check - uses: actions-rs/cargo@v1 - with: - command: clippy - documentation: runs-on: ubuntu-latest steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index c375ac87..6301c4f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,70 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.6.2](https://github.com/quartiq/miniconf/compare/v0.6.1...v0.6.2) - 2022-11-09 + +* Renaming and reorganization of the the derive macro + +## [0.6.1](https://github.com/quartiq/miniconf/compare/v0.6.0...v0.6.1) - 2022-11-04 + +* Documentation updates. + +## [0.6.0](https://github.com/quartiq/miniconf/compare/v0.5.0...v0.6.0) - 2022-11-04 + +### Changed +* python module: don't emite whitespace in JSON to match serde-json-core (#92) +* `heapless::String` now implements `Miniconf` directly. +* Python client API is no longer retain by default. CLI is unchanged +* [breaking] Support for `#[derive(MiniconfAtomic)]` was removed. +* Fields in `#[derive(Miniconf)]` are now atomic by default. To recurse, users must + annotate fields with `#[miniconf(defer)]` +* New `miniconf::Option` type has been added. Existing `Option` implementation has been changed to + allow run-time nullability of values for more flexibility. +* New `miniconf::Array` type has been added, replacing the previous [T; N] implementation +* `Miniconf` implementation on most primitive types has been removed as it is no longer required. +* [breaking] The API has changed to be agnostic to usage (e.g. now referring to namespace paths and values + instead of topics and settings). Functions in the `Miniconf` trait have been renamed. +* [breaking] Errors and the Metadata struct have beem marked `#[non_exhaustive]` +* [breaking] `metadata()`, `unchecked_iter_paths()`, `iter_paths()`, `next_path()` are + all associated functions now. +* [breaking] Path iteration has been changed to move ownership of the iteration state into the iterator. + And the path depth is now a const generic. +* [breaking] Path iteration will always return all paths regardless of potential runtime `miniconf::Option` + or deferred `Option` being `None`. +* [breaking] `unchecked_iter_paths()` takes an optional iterator size to be used in `Iterator::size_hint()`. +* MQTT client now publishes responses with a quality of service of at-least-once to ensure + transmission. +* MQTT client no longer uses correlation data to ignore local transmissions. + +### Fixed +* Python device discovery now only discovers unique device identifiers. See [#97](https://github.com/quartiq/miniconf/issues/97) +* Python requests API updated to use a static response topic +* Python requests now have a timeout +* Generic trait bound requirements have been made more specific. + + +## [0.5.0] - 2022-05-12 + +### Changed +* **breaking** The Miniconf trait for iteration was renamed from `unchecked_iter()` and `iter()` to + `unchecked_iter_settings()` and `iter_settings()` respectively to avoid issues with slice iteration + name conflicts. See [#87](https://github.com/quartiq/miniconf/issues/87) + +## [0.4.0] - 2022-05-11 ### Added +* Added support for custom handling of settings updates. +* `Option` support added to enable run-time settings tree presence. + ### Changed +* [breaking] MqttClient constructor now accepts initial settings values. +* Settings republish will no longer register as incoming configuration requests. See + [#71](https://github.com/quartiq/miniconf/issues/71) +* [breaking] `into_iter()` and `unchecked_into_iter()` renamed to `iter()` and `unchecked_iter()` + respectively to conform with standard conventions. + ### Removed +* The client no longer resets the republish timeout when receiving messages. ## [0.3.0] - 2021-12-13 @@ -24,7 +83,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * `miniconf::update()` replaced with `Miniconf::set()`, which is part of the trait and now directly available on structures. - ## [0.2.0] - 2021-10-28 ### Added @@ -38,7 +96,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Library initially released on crates.io -[Unreleased]: https://github.com/quartiq/miniconf/compare/v0.3.0...HEAD +[0.5.0]: https://github.com/quartiq/miniconf/compare/v0.4.0...v0.5.0 +[0.4.0]: https://github.com/quartiq/miniconf/compare/v0.3.0...v0.4.0 [0.3.0]: https://github.com/quartiq/miniconf/releases/tag/v0.3.0 [0.2.0]: https://github.com/quartiq/miniconf/releases/tag/v0.2.0 [0.1.0]: https://github.com/quartiq/miniconf/releases/tag/v0.1.0 diff --git a/Cargo.toml b/Cargo.toml index 9295d7a4..e2fb5d79 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,22 +1,25 @@ [package] name = "miniconf" -version = "0.3.0" -authors = ["James Irwin ", "Ryan Summers ", "Ryan Summers "] edition = "2018" license = "MIT" -description = "Lightweight support for run-time settings configuration" +description = "Inspect serde namespaces by path" repository = "https://github.com/quartiq/miniconf" -keywords = ["settings", "embedded", "no_std", "configuration", "mqtt"] -categories = ["no-std", "config", "embedded", "parsing"] +keywords = ["settings", "serde", "no_std", "json", "mqtt"] +categories = ["no-std", "config", "rust-patterns", "parsing"] [dependencies] -derive_miniconf = { path = "derive_miniconf" , version = "0.3" } -serde-json-core = "0.4.0" +miniconf_derive = { path = "miniconf_derive" , version = "0.6" } +serde-json-core = "0.5.0" serde = { version = "1.0.120", features = ["derive"], default-features = false } log = "0.4" heapless = { version = "0.7", features = ["serde"] } -minimq = { version = "^0.5.1", optional = true } -smlang = { version = "0.4", optional = true } +minimq = { version = "^0.6.1", optional = true } +smlang = { version = "0.6", optional = true } [features] default = ["mqtt-client"] diff --git a/README.md b/README.md index 85d8ca80..456c7b1a 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,149 @@ -# MiniConf - +# Miniconf +[![crates.io](https://img.shields.io/crates/v/miniconf.svg)](https://crates.io/crates/miniconf) +[![docs](https://docs.rs/miniconf/badge.svg)](https://docs.rs/miniconf) [![QUARTIQ Matrix Chat](https://img.shields.io/matrix/quartiq:matrix.org)](https://matrix.to/#/#quartiq:matrix.org) -![Continuous Integration](https://github.com/vertigo-designs/miniconf/workflows/Continuous%20Integration/badge.svg) - -MiniConf is a `no_std` minimal run-time settings configuration tool designed to be run on top of -any communication means. It was originally designed to work with MQTT clients and provides a default -implementation using [minimq](https://github.com/quartiq/minimq) as the MQTT client. +[![Continuous Integration](https://github.com/vertigo-designs/miniconf/workflows/Continuous%20Integration/badge.svg)](https://github.com/quartiq/miniconf/actions) -# Design +Miniconf enables lightweight (`no_std`) partial serialization (retrieval) and deserialization +(updates, modification) within a hierarchical namespace by path. The namespace is backed by +structs and arrays of serializable types. -Miniconf provides an easy-to-work-with API for quickly adding runtime-configured settings to any -embedded project. This allows any internet-connected device to quickly bring up configuration -interfaces with minimal implementation in the end-user application. +Miniconf can be used as a very simple and flexible backend for run-time settings management in embedded devices +over any transport. It was originally designed to work with JSON ([serde_json_core](https://docs.rs/serde-json-core)) +payloads over MQTT ([minimq](https://docs.rs/minimq)) and provides a comlete [MQTT settings management +client](MqttClient) and a Python reference implementation to ineract with it. -MiniConf provides a `Miniconf` derive macro for creating a settings structure, e.g.: +## Example ```rust use miniconf::Miniconf; +use serde::{Serialize, Deserialize}; -#[derive(Miniconf)] -struct NestedSettings { - inner: f32, +#[derive(Deserialize, Serialize, Copy, Clone, Default)] +enum Either { + #[default] + Bad, + Good, } -#[derive(Miniconf)] -struct MySettings { - initial_value: u32, - internal: NestedSettings, +#[derive(Deserialize, Serialize, Copy, Clone, Default, Miniconf)] +struct Inner { + a: i32, + b: i32, } -``` -# Settings Paths +#[derive(Miniconf, Default)] +struct Settings { + // Atomic updtes by field name + foo: bool, + enum_: Either, + struct_: Inner, + array: [i32; 2], + option: Option, -A setting value must be configured via a specific path. Paths take the form of variable names -separated by slashes - this design follows typical MQTT topic design semantics. For example, with -the following `Settings` structure: -```rust -#[derive(Miniconf)] -struct Data { - inner: f32, + // Exposing elements of containers + // ... by field name + #[miniconf(defer)] + struct_defer: Inner, + // ... or by index + #[miniconf(defer)] + array_defer: [i32; 2], + // ... or deferring to two levels (index and then inner field name) + #[miniconf(defer)] + array_miniconf: miniconf::Array, + + // Hiding paths by setting the Option to `None` at runtime + #[miniconf(defer)] + option_defer: Option, + // Hiding a path and deferring to the inner + #[miniconf(defer)] + option_miniconf: miniconf::Option } -#[derive(Miniconf)] -struct Settings { - initial_value: u32, - internal: Data, +let mut settings = Settings::default(); +let mut buf = [0; 64]; + +// Atomic updates by field name +settings.set("foo", b"true")?; +assert_eq!(settings.foo, true); + +settings.set("enum_", br#""Good""#)?; +settings.set("struct_", br#"{"a": 3, "b": 3}"#)?; +settings.set("array", b"[6, 6]")?; +settings.set("option", b"12")?; +settings.set("option", b"null")?; + +// Deep access by field name in a struct +settings.set("struct_defer/a", b"4")?; +// ... or by index in an array +settings.set("array_defer/0", b"7")?; +// ... or by index and then struct field name +settings.set("array_miniconf/1/b", b"11")?; + +// If a deferred Option is `None` it is hidden at runtime +settings.set("option_defer", b"13").unwrap_err(); +settings.option_defer = Some(0); +settings.set("option_defer", b"13")?; +settings.set("option_miniconf/a", b"14").unwrap_err(); +*settings.option_miniconf = Some(Inner::default()); +settings.set("option_miniconf/a", b"14")?; + +// Serializing an element by path +let len = settings.get("foo", &mut buf)?; +assert_eq!(&buf[..len], b"true"); + +// Iterating over all elements +for path in Settings::iter_paths::<3, 32>().unwrap() { + let len = settings.get(&path, &mut buf)?; + if path.as_str() == "option_miniconf/a" { + assert_eq!(&buf[..len], b"14"); + } } + +# Ok::<(), miniconf::Error>(()) +``` + +## MQTT +There is an [MQTT-based client](MqttClient) that implements settings management over the [MQTT +protocol](https://mqtt.org) with JSON payloads. A Python reference library is provided that +interfaces with it. + +```sh +# Discover the complete unique prefix of an application listening to messages +# under the topic `quartiq/application/12345` and set its `foo` setting to `true`. +python -m miniconf -d quartiq/application/+ foo=true ``` -We can access `Data::inner` with the path `internal/inner`. +## Design +For structs with named fields, Miniconf offers a [derive macro](derive.Miniconf.html) to automatically +assign a unique path to each item in the namespace of the struct. +The macro implements the [Miniconf] trait that exposes access to serialized field values through their path. +All types supported by [serde_json_core] can be used as fields. + +Elements of homogeneous [core::array]s are similarly accessed through their numeric indices. +Structs, arrays, and Options can then be cascaded to construct a multi-level namespace. +Namespace depth and access to individual elements instead of the atomic updates +is configured at compile (derive) time using the `#[miniconf(defer)]` attribute. +`Option` is used with `#[miniconf(defer)]` to support paths that may be absent (masked) at +runtime. + +While the [Miniconf] implementations for [core::array] and [core::option::Option] by provide +atomic access to their respective inner element(s), [Array] and +[Option] have alternative [Miniconf] implementations that expose deep access +into the inner element(s) through their respective inner [Miniconf] implementations. + +## Formats +The path hierarchy separator is the slash `/`. + +Values are serialized into and deserialized from JSON. -Settings may only be updated at the terminal node. That is, you cannot configure -`/settings/internal` directly. If this is desired, instead derive `MiniconfAtomic` on the -`struct Data` definition. In this way, all members of `struct Data` must be updated simultaneously. +## Transport +Miniconf is designed to be protocol-agnostic. Any means that can receive key-value input from +some external source can be used to modify values by path. -# Settings Values +## Limitations +Deferred (non-atomic) access to inner elements of some types is not yet supported. This includes: +* Complex enums (other than [core::option::Option]) +* Tuple structs (other than [Option], [Array]) -MiniConf relies on using [`serde`](https://github.com/serde-rs/serde) for defining a -de/serialization method for settings. Currently, MiniConf only supports serde-json de/serialization -formats, although more formats may be supported in the future. +## Features +* `mqtt-client` Enabled the MQTT client feature. See the example in [MqttClient]. diff --git a/derive_miniconf/Cargo.toml b/derive_miniconf/Cargo.toml deleted file mode 100644 index 6579275e..00000000 --- a/derive_miniconf/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "derive_miniconf" -version = "0.3.0" -authors = ["James Irwin ", "Ryan Summers Self { - let mut typedef = TypeDefinition { generics, name }; - typedef.bound_generics(); - - typedef - } - - /// Bound the generated type definition to only implement when `Self: DeserializeOwned` for - /// cases when deserialization is required. - /// - /// # Note - /// This is equivalent to adding: - /// `where Self: DeserializeOwned` to the type definition. - pub fn add_serde_bound(&mut self) { - let where_clause = self.generics.make_where_clause(); - where_clause - .predicates - .push(parse_quote!(Self: miniconf::DeserializeOwned)); - where_clause - .predicates - .push(parse_quote!(Self: miniconf::Serialize)); - } - - // Bound all generics of the type with `T: miniconf::DeserializeOwned + Miniconf`. This is necessary to - // make `MiniconfAtomic` and enum derives work properly. - fn bound_generics(&mut self) { - for generic in &mut self.generics.params { - if let syn::GenericParam::Type(type_param) = generic { - type_param - .bounds - .push(parse_quote!(miniconf::DeserializeOwned)); - type_param.bounds.push(parse_quote!(miniconf::Serialize)); - type_param.bounds.push(parse_quote!(miniconf::Miniconf)); - } - } - } -} - -/// Derive the Miniconf trait for custom types. -/// -/// Each field of the struct will be recursively used to construct a unique path for all elements. -/// -/// All settings paths are similar to file-system paths with variable names separated by forward -/// slashes. -/// -/// For arrays, the array index is treated as a unique identifier. That is, to access the first -/// element of array `test`, the path would be `test/0`. -/// -/// # Example -/// ```rust -/// #[derive(Miniconf)] -/// struct Nested { -/// data: [u32; 2], -/// } -/// #[derive(Miniconf)] -/// struct Settings { -/// // Accessed with path `nested/data/0` or `nested/data/1` -/// nested: Nested, -/// -/// // Accessed with path `external` -/// external: bool, -/// } -#[proc_macro_derive(Miniconf)] -pub fn derive(input: TokenStream) -> TokenStream { - let input = parse_macro_input!(input as DeriveInput); - - let typedef = TypeDefinition::new(input.generics, input.ident); - - match input.data { - syn::Data::Struct(struct_data) => derive_struct(typedef, struct_data, false), - syn::Data::Enum(enum_data) => derive_enum(typedef, enum_data), - syn::Data::Union(_) => unimplemented!(), - } -} - -/// Derive the Miniconf trait for a custom type that must be updated atomically. -/// -/// This derive function should be used if the setting must be updated entirely at once (e.g. -/// individual portions of the struct may not be updated independently). -/// -/// See [Miniconf](derive.Miniconf.html) for more information. -/// -/// # Example -/// ```rust -/// #[derive(MiniconfAtomic)] -/// struct FilterParameters { -/// coefficient: f32, -/// length: usize, -/// } -/// -/// #[derive(Miniconf)] -/// struct Settings { -/// // Accessed with path `filter`, but `filter/length` and `filter/coefficients` are -/// inaccessible. -/// filter: FilterParameters, -/// -/// // Accessed with path `external` -/// external: bool, -/// } -#[proc_macro_derive(MiniconfAtomic)] -pub fn derive_atomic(input: TokenStream) -> TokenStream { - let input = parse_macro_input!(input as DeriveInput); - - let typedef = TypeDefinition::new(input.generics, input.ident); - - match input.data { - syn::Data::Struct(struct_data) => derive_struct(typedef, struct_data, true), - syn::Data::Enum(enum_data) => derive_enum(typedef, enum_data), - syn::Data::Union(_) => unimplemented!(), - } -} - -/// Derive the Miniconf trait for structs. -/// -/// # Args -/// * `typedef` - The type definition. -/// * `data` - The data associated with the struct definition. -/// * `atomic` - specified true if the data must be updated atomically. If false, data must be -/// set at a terminal node. -/// -/// # Returns -/// A token stream of the generated code. -fn derive_struct(mut typedef: TypeDefinition, data: syn::DataStruct, atomic: bool) -> TokenStream { - let fields = match data.fields { - syn::Fields::Named(syn::FieldsNamed { ref named, .. }) => named, - _ => unimplemented!("Only named fields are supported in structs."), - }; - - // If this structure must be updated atomically, it is not valid to call Miniconf recursively - // on its members. - if atomic { - // Bound the Miniconf implementation on Self implementing DeserializeOwned + Serialize. - typedef.add_serde_bound(); - - let name = typedef.name; - let (impl_generics, ty_generics, where_clause) = typedef.generics.split_for_impl(); - - let data = quote! { - impl #impl_generics miniconf::Miniconf for #name #ty_generics #where_clause { - fn string_set(&mut self, mut topic_parts: - core::iter::Peekable>, value: &[u8]) -> - Result<(), miniconf::Error> { - if topic_parts.peek().is_some() { - return Err(miniconf::Error::AtomicUpdateRequired); - } - - *self = miniconf::serde_json_core::from_slice(value)?.0; - Ok(()) - } - - fn string_get(&self, mut topic_parts: core::iter::Peekable>, value: &mut [u8]) -> Result { - if topic_parts.peek().is_some() { - return Err(miniconf::Error::AtomicUpdateRequired); - } - - miniconf::serde_json_core::to_slice(self, value).map_err(|_| miniconf::Error::SerializationFailed) - } - - fn get_metadata(&self) -> miniconf::MiniconfMetadata { - // Atomic structs have no children and a single index. - miniconf::MiniconfMetadata { - max_topic_size: 0, - max_depth: 1, - } - } - - fn recurse_paths(&self, index: &mut [usize], topic: &mut miniconf::heapless::String) -> Option<()> { - if index.len() == 0 { - // Note: During expected execution paths using `into_iter()`, the size of - // the index stack is checked in advance to make sure this condition - // doesn't occur. However, it's possible to happen if the user manually - // calls `recurse_paths`. - unreachable!("Index stack too small"); - } - - let i = index[0]; - index[0] += 1; - - if i == 0 { - Some(()) - } else { - None - } - } - } - }; - - return TokenStream::from(data); - } - - let set_recurse_match_arms = fields.iter().map(|f| { - let match_name = &f.ident; - quote! { - stringify!(#match_name) => { - self.#match_name.string_set(topic_parts, value) - } - } - }); - - let get_recurse_match_arms = fields.iter().map(|f| { - let match_name = &f.ident; - quote! { - stringify!(#match_name) => { - self.#match_name.string_get(topic_parts, value) - } - } - }); - - let iter_match_arms = fields.iter().enumerate().map(|(i, f)| { - let field_name = &f.ident; - quote! { - #i => { - let original_length = topic.len(); - - let postfix = if topic.len() != 0 { - concat!("/", stringify!(#field_name)) - } else { - stringify!(#field_name) - }; - - if topic.push_str(postfix).is_err() { - // Note: During expected execution paths using `into_iter()`, the size of the - // topic buffer is checked in advance to make sure this condition doesn't - // occur. However, it's possible to happen if the user manually calls - // `recurse_paths`. - unreachable!("Topic buffer too short"); - } - - if self.#field_name.recurse_paths(&mut index[1..], topic).is_some() { - return Some(()); - } - - // Strip off the previously prepended index, since we completed that element and need - // to instead check the next one. - topic.truncate(original_length); - - index[0] += 1; - index[1..].iter_mut().for_each(|x| *x = 0); - } - } - }); - - let iter_metadata_arms = fields.iter().enumerate().map(|(i, f)| { - let field_name = &f.ident; - quote! { - #i => { - let mut meta = self.#field_name.get_metadata(); - - // If the subfield has additional paths, we need to add space for a separator. - if meta.max_topic_size > 0 { - meta.max_topic_size += 1; - } - - meta.max_topic_size += stringify!(#field_name).len(); - - meta - } - } - }); - - let (impl_generics, ty_generics, where_clause) = typedef.generics.split_for_impl(); - let name = typedef.name; - - let expanded = quote! { - impl #impl_generics miniconf::Miniconf for #name #ty_generics #where_clause { - fn string_set(&mut self, mut topic_parts: - core::iter::Peekable>, value: &[u8]) -> - Result<(), miniconf::Error> { - let field = topic_parts.next().ok_or(miniconf::Error::PathTooShort)?; - - match field { - #(#set_recurse_match_arms ,)* - _ => Err(miniconf::Error::PathNotFound) - } - } - - fn string_get(&self, mut topic_parts: core::iter::Peekable>, value: &mut [u8]) -> Result { - let field = topic_parts.next().ok_or(miniconf::Error::PathTooShort)?; - - match field { - #(#get_recurse_match_arms ,)* - _ => Err(miniconf::Error::PathNotFound) - } - } - - fn get_metadata(&self) -> miniconf::MiniconfMetadata { - // Loop through all child elements, collecting the maximum length + depth of any - // member. - let mut maximum_sizes = miniconf::MiniconfMetadata { - max_topic_size: 0, - max_depth: 0 - }; - - let mut index = 0; - loop { - let metadata = match index { - #(#iter_metadata_arms ,)* - _ => break, - }; - - maximum_sizes.max_topic_size = core::cmp::max(maximum_sizes.max_topic_size, - metadata.max_topic_size); - maximum_sizes.max_depth = core::cmp::max(maximum_sizes.max_depth, - metadata.max_depth); - - index += 1; - } - - // We need an additional index depth for this node. - maximum_sizes.max_depth += 1; - - maximum_sizes - } - - fn recurse_paths(&self, index: &mut [usize], topic: &mut miniconf::heapless::String) -> Option<()> { - if index.len() == 0 { - // Note: During expected execution paths using `into_iter()`, the size of the - // index stack is checked in advance to make sure this condition doesn't occur. - // However, it's possible to happen if the user manually calls `recurse_paths`. - unreachable!("Index stack too small"); - } - - loop { - match index[0] { - #(#iter_match_arms ,)* - _ => return None, - - }; - } - } - } - }; - - TokenStream::from(expanded) -} - -/// Derive the Miniconf trait for simple enums. -/// -/// # Args -/// * `typedef` - The type definition. -/// * `data` - The data associated with the enum definition. -/// -/// # Returns -/// A token stream of the generated code. -fn derive_enum(mut typedef: TypeDefinition, data: syn::DataEnum) -> TokenStream { - // Only support simple enums, check each field - for v in data.variants.iter() { - match v.fields { - syn::Fields::Named(_) | syn::Fields::Unnamed(_) => { - unimplemented!("Only simple, C-like enums are supported.") - } - syn::Fields::Unit => {} - } - } - - typedef.add_serde_bound(); - - let (impl_generics, ty_generics, where_clause) = typedef.generics.split_for_impl(); - let name = typedef.name; - - let expanded = quote! { - impl #impl_generics miniconf::Miniconf for #name #ty_generics #where_clause { - fn string_set(&mut self, mut topic_parts: - core::iter::Peekable>, value: &[u8]) -> - Result<(), miniconf::Error> { - if topic_parts.peek().is_some() { - // We don't support enums that can contain other values - return Err(miniconf::Error::PathTooLong) - } - - *self = miniconf::serde_json_core::from_slice(value)?.0; - Ok(()) - } - - fn string_get(&self, mut topic_parts: core::iter::Peekable>, value: &mut [u8]) -> Result { - if topic_parts.peek().is_some() { - // We don't support enums that can contain other values - return Err(miniconf::Error::PathTooLong) - } - - miniconf::serde_json_core::to_slice(self, value).map_err(|_| miniconf::Error::SerializationFailed) - } - - fn get_metadata(&self) -> miniconf::MiniconfMetadata { - // Atomic structs have no children and a single index. - miniconf::MiniconfMetadata { - max_topic_size: 0, - max_depth: 1, - } - } - - fn recurse_paths(&self, index: &mut [usize], topic: &mut miniconf::heapless::String) -> Option<()> { - if index.len() == 0 { - // Note: During expected execution paths using `into_iter()`, the size of the - // index stack is checked in advance to make sure this condition doesn't occur. - // However, it's possible to happen if the user manually calls `recurse_paths`. - unreachable!("Index stack too small"); - } - - let i = index[0]; - index[0] += 1; - - if i == 0 { - Some(()) - } else { - None - } - } - } - }; - - TokenStream::from(expanded) -} diff --git a/examples/mqtt.rs b/examples/mqtt.rs index 5c773737..940bee61 100644 --- a/examples/mqtt.rs +++ b/examples/mqtt.rs @@ -1,18 +1,22 @@ use miniconf::{Miniconf, MqttClient}; -use minimq::{Minimq, QoS, Retain}; +use minimq::{Minimq, Publication}; use std::time::Duration; use std_embedded_nal::Stack; use std_embedded_time::StandardClock; -#[derive(Default, Miniconf, Debug)] +#[derive(Clone, Default, Miniconf, Debug)] struct NestedSettings { frame_rate: u32, } -#[derive(Default, Miniconf, Debug)] +#[derive(Clone, Default, Miniconf, Debug)] struct Settings { + #[miniconf(defer)] inner: NestedSettings, + + #[miniconf(defer)] amplitude: [f32; 2], + exit: bool, } @@ -27,7 +31,7 @@ async fn mqtt_client() { .unwrap(); // Wait for the broker connection - while !mqtt.client.is_connected() { + while !mqtt.client().is_connected() { mqtt.poll(|_client, _topic, _message, _properties| {}) .unwrap(); tokio::time::sleep(Duration::from_millis(100)).await; @@ -37,35 +41,32 @@ async fn mqtt_client() { tokio::time::sleep(Duration::from_secs(1)).await; // Configure settings. - mqtt.client + mqtt.client() .publish( - "sample/prefix/settings/amplitude/0", - b"32.4", - QoS::AtMostOnce, - Retain::NotRetained, - &[], + Publication::new(b"32.4") + .topic("sample/prefix/settings/amplitude/0") + .finish() + .unwrap(), ) .unwrap(); tokio::time::sleep(Duration::from_millis(100)).await; - mqtt.client + mqtt.client() .publish( - "sample/prefix/settings/inner/frame_rate", - b"10", - QoS::AtMostOnce, - Retain::NotRetained, - &[], + Publication::new(b"10") + .topic("sample/prefix/settings/inner/frame_rate") + .finish() + .unwrap(), ) .unwrap(); tokio::time::sleep(Duration::from_millis(100)).await; - mqtt.client + mqtt.client() .publish( - "sample/prefix/settings/exit", - b"true", - QoS::AtMostOnce, - Retain::NotRetained, - &[], + Publication::new(b"true") + .topic("sample/prefix/settings/exit") + .finish() + .unwrap(), ) .unwrap(); } @@ -81,6 +82,7 @@ async fn main() { "sample/prefix", "127.0.0.1".parse().unwrap(), StandardClock::default(), + Settings::default(), ) .unwrap(); diff --git a/examples/readback.rs b/examples/readback.rs index 9e00299a..bbb39a62 100644 --- a/examples/readback.rs +++ b/examples/readback.rs @@ -1,15 +1,14 @@ -// use miniconf::{Error, Miniconf}; use miniconf::Miniconf; -use serde::{Deserialize, Serialize}; -#[derive(Debug, Default, Miniconf, Serialize, Deserialize)] +#[derive(Debug, Default, Miniconf)] struct AdditionalSettings { inner: u8, inner2: u32, } -#[derive(Debug, Default, Miniconf, Serialize, Deserialize)] +#[derive(Debug, Default, Miniconf)] struct Settings { + #[miniconf(defer)] more: AdditionalSettings, data: u32, } @@ -23,14 +22,8 @@ fn main() { }, }; - // Maintains our state of iteration. This is created external from the - // iterator struct so that we can destroy the iterator struct, create a new - // one, and resume from where we left off. - // Perhaps we can wrap this up as some sort of templated `MiniconfIterState` - // type? That way we can hide what it is. - let mut iterator_state = [0; 5]; - - let mut settings_iter = s.into_iter::<128>(&mut iterator_state).unwrap(); + // Maintains our state of iteration. + let mut settings_iter = Settings::iter_paths::<5, 128>().unwrap(); // Just get one topic/value from the iterator if let Some(topic) = settings_iter.next() { @@ -47,8 +40,7 @@ fn main() { // the settings s.data = 3; - // Create a new settings iterator, print remaining values - for topic in s.into_iter::<128>(&mut iterator_state).unwrap() { + for topic in settings_iter { let mut value = [0; 256]; let len = s.get(&topic, &mut value).unwrap(); println!( diff --git a/derive_miniconf/.gitignore b/miniconf_derive/.gitignore similarity index 100% rename from derive_miniconf/.gitignore rename to miniconf_derive/.gitignore diff --git a/miniconf_derive/Cargo.toml b/miniconf_derive/Cargo.toml new file mode 100644 index 00000000..543bf8d1 --- /dev/null +++ b/miniconf_derive/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "miniconf_derive" +version = "0.6.2" +authors = ["James Irwin ", "Ryan Summers "] +edition = "2018" +license = "MIT" +description = "Inspect serde namespaces by path (Miniconf derive macro)" +repository = "https://github.com/quartiq/miniconf" +keywords = ["settings", "serde", "no_std", "json", "mqtt"] +categories = ["no-std", "config", "rust-patterns", "parsing"] + +[lib] +proc-macro = true + +[dependencies] +syn = { version="1.0.58", features=["extra-traits"] } +quote = "1.0.8" +proc-macro2 = "1.0.24" diff --git a/miniconf_derive/src/attributes.rs b/miniconf_derive/src/attributes.rs new file mode 100644 index 00000000..de2c6490 --- /dev/null +++ b/miniconf_derive/src/attributes.rs @@ -0,0 +1,45 @@ +use proc_macro2::token_stream::IntoIter as TokenIter; +use proc_macro2::{TokenStream, TokenTree}; +use std::str::FromStr; + +#[derive(Copy, Clone, Debug, PartialEq)] +pub enum MiniconfAttribute { + Defer, +} + +impl FromStr for MiniconfAttribute { + type Err = String; + fn from_str(s: &str) -> Result { + let attr = match s { + "defer" => MiniconfAttribute::Defer, + other => return Err(format!("Unknown attribute: {other}")), + }; + + Ok(attr) + } +} + +pub struct AttributeParser { + inner: TokenIter, +} + +impl AttributeParser { + pub fn new(stream: TokenStream) -> Self { + Self { + inner: stream.into_iter(), + } + } + + pub fn parse(&mut self) -> MiniconfAttribute { + let first = self.inner.next().expect("A single keyword"); + + match first { + TokenTree::Group(group) => { + let ident: syn::Ident = syn::parse2(group.stream()).expect("An identifier"); + + MiniconfAttribute::from_str(&ident.to_string()).unwrap() + } + other => panic!("Unexpected tree: {:?}", other), + } + } +} diff --git a/miniconf_derive/src/field.rs b/miniconf_derive/src/field.rs new file mode 100644 index 00000000..abd5b6e8 --- /dev/null +++ b/miniconf_derive/src/field.rs @@ -0,0 +1,92 @@ +use super::attributes::{AttributeParser, MiniconfAttribute}; +use syn::{parse_quote, Generics}; + +pub struct StructField { + pub field: syn::Field, + pub deferred: bool, +} + +impl StructField { + pub fn new(field: syn::Field) -> Self { + let attributes: Vec = field + .attrs + .iter() + .filter(|attr| attr.path.is_ident("miniconf")) + .map(|attr| AttributeParser::new(attr.tokens.clone()).parse()) + .collect(); + + let deferred = attributes.iter().any(|x| *x == MiniconfAttribute::Defer); + + Self { deferred, field } + } + + fn bound_type(&self, ident: &syn::Ident, generics: &mut Generics, array: bool) { + for generic in &mut generics.params { + if let syn::GenericParam::Type(type_param) = generic { + if type_param.ident == *ident { + // Deferred array types are a special case. These types defer directly into a + // manual implementation of Miniconf that calls serde functions directly. + if self.deferred && !array { + // For deferred, non-array data types, we will recursively call into + // Miniconf trait functions. + type_param.bounds.push(parse_quote!(miniconf::Miniconf)); + } else { + // For other data types, we will call into serde functions directly. + type_param.bounds.push(parse_quote!(miniconf::Serialize)); + type_param + .bounds + .push(parse_quote!(miniconf::DeserializeOwned)); + } + } + } + } + } + + /// Handle an individual type encountered in the field type definition. + /// + /// # Note + /// This function will recursively travel through arrays. + /// + /// # Note + /// Only arrays and simple types are currently implemented for type bounds. + /// + /// # Args + /// * `typ` The Type encountered. + /// * `generics` - The generic type parameters of the structure. + /// * `array` - Specified true if this type belongs to an upper-level array type. + fn handle_type(&self, typ: &syn::Type, generics: &mut Generics, array: bool) { + // Check our type. Path-like types may need to be bound. + let path = match &typ { + syn::Type::Path(syn::TypePath { path, .. }) => path, + syn::Type::Array(syn::TypeArray { elem, .. }) => { + self.handle_type(elem, generics, true); + return; + } + other => panic!("Unsupported type: {:?}", other), + }; + + // Generics will have an ident only as the type. Grab it. + if let Some(ident) = path.get_ident() { + self.bound_type(ident, generics, array); + } + + // Search for generics in the type signature. + for segment in path.segments.iter() { + if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { + for arg in args.args.iter() { + if let syn::GenericArgument::Type(typ) = arg { + self.handle_type(typ, generics, array); + } + } + } + } + } + + /// Bound the generic parameters of the field. + /// + /// # Args + /// * `generics` The generics for the structure. + pub(crate) fn bound_generics(&self, generics: &mut Generics) { + self.handle_type(&self.field.ty, generics, false) + } +} diff --git a/miniconf_derive/src/lib.rs b/miniconf_derive/src/lib.rs new file mode 100644 index 00000000..03ae25ec --- /dev/null +++ b/miniconf_derive/src/lib.rs @@ -0,0 +1,261 @@ +use proc_macro::TokenStream; +use quote::quote; +use syn::{parse_macro_input, DeriveInput}; + +mod attributes; +mod field; + +use field::StructField; + +/// Derive the Miniconf trait for custom types. +/// +/// Each field of the struct will be recursively used to construct a unique path for all elements. +/// +/// All paths are similar to file-system paths with variable names separated by forward +/// slashes. +/// +/// For arrays, the array index is treated as a unique identifier. That is, to access the first +/// element of array `data`, the path would be `data/0`. +/// +/// # Example +/// ```rust +/// #[derive(Miniconf)] +/// struct Nested { +/// #[miniconf(defer)] +/// data: [u32; 2], +/// } +/// #[derive(Miniconf)] +/// struct Settings { +/// // Accessed with path `nested/data/0` or `nested/data/1` +/// #[miniconf(defer)] +/// nested: Nested, +/// +/// // Accessed with path `external` +/// external: bool, +/// } +#[proc_macro_derive(Miniconf, attributes(miniconf))] +pub fn derive(input: TokenStream) -> TokenStream { + let mut input = parse_macro_input!(input as DeriveInput); + + match input.data { + syn::Data::Struct(ref data) => derive_struct(data, &mut input.generics, &input.ident), + _ => unimplemented!(), + } +} + +fn get_path_arm(struct_field: &StructField) -> proc_macro2::TokenStream { + // Quote context is a match of the field name with `self`, `path_parts`, `peek`, and `value` available. + let match_name = &struct_field.field.ident; + if struct_field.deferred { + quote! { + stringify!(#match_name) => { + self.#match_name.get_path(path_parts, value) + } + } + } else { + quote! { + stringify!(#match_name) => { + if peek { + Err(miniconf::Error::PathTooLong) + } else { + Ok(miniconf::serde_json_core::to_slice(&self.#match_name, value)?) + } + } + } + } +} + +fn set_path_arm(struct_field: &StructField) -> proc_macro2::TokenStream { + // Quote context is a match of the field name with `self`, `path_parts`, `peek`, and `value` available. + let match_name = &struct_field.field.ident; + if struct_field.deferred { + quote! { + stringify!(#match_name) => { + self.#match_name.set_path(path_parts, value) + } + } + } else { + quote! { + stringify!(#match_name) => { + if peek { + Err(miniconf::Error::PathTooLong) + } else { + let (value, len) = miniconf::serde_json_core::from_slice(value)?; + self.#match_name = value; + Ok(len) + } + } + } + } +} + +fn next_path_arm((i, struct_field): (usize, &StructField)) -> proc_macro2::TokenStream { + // Quote context is a match of the field index with `self`, `state`, and `path` available. + let field_type = &struct_field.field.ty; + let field_name = &struct_field.field.ident; + if struct_field.deferred { + quote! { + #i => { + path.push_str(concat!(stringify!(#field_name), "/")) + .map_err(|_| miniconf::IterError::PathLength)?; + + if <#field_type>::next_path(&mut state[1..], path)? { + return Ok(true); + } + } + } + } else { + quote! { + #i => { + path.push_str(stringify!(#field_name)) + .map_err(|_| miniconf::IterError::PathLength)?; + state[0] += 1; + + return Ok(true); + } + } + } +} + +fn metadata_arm((i, struct_field): (usize, &StructField)) -> proc_macro2::TokenStream { + // Quote context is a match of the field index. + let field_type = &struct_field.field.ty; + let field_name = &struct_field.field.ident; + if struct_field.deferred { + quote! { + #i => { + let mut meta = <#field_type>::metadata(); + + // Unconditionally account for separator since we add it + // even if elements that are deferred to (`Options`) + // may have no further hierarchy to add and remove the separator again. + meta.max_length += stringify!(#field_name).len() + 1; + meta.max_depth += 1; + + meta + } + } + } else { + quote! { + #i => { + let mut meta = miniconf::Metadata::default(); + + meta.max_length = stringify!(#field_name).len(); + meta.max_depth = 1; + meta.count = 1; + + meta + } + } + } +} + +/// Derive the Miniconf trait for structs. +/// +/// # Args +/// * `data` - The data associated with the struct definition. +/// * `generics` - The generics of the definition. Sufficient bounds will be added here. +/// * `ident` - The identifier to derive the impl for. +/// +/// # Returns +/// A token stream of the generated code. +fn derive_struct( + data: &syn::DataStruct, + generics: &mut syn::Generics, + ident: &syn::Ident, +) -> TokenStream { + let fields: Vec<_> = match &data.fields { + syn::Fields::Named(syn::FieldsNamed { named, .. }) => { + named.iter().cloned().map(StructField::new).collect() + } + _ => unimplemented!("Only named fields are supported in structs."), + }; + fields.iter().for_each(|f| f.bound_generics(generics)); + + let set_path_arms = fields.iter().map(set_path_arm); + let get_path_arms = fields.iter().map(get_path_arm); + let next_path_arms = fields.iter().enumerate().map(next_path_arm); + let metadata_arms = fields.iter().enumerate().map(metadata_arm); + + let (impl_generics, ty_generics, where_clause) = generics.split_for_impl(); + + quote! { + impl #impl_generics miniconf::Miniconf for #ident #ty_generics #where_clause { + fn set_path<'a, P: miniconf::Peekable>( + &mut self, + path_parts: &'a mut P, + value: &[u8] + ) -> Result { + let field = path_parts.next().ok_or(miniconf::Error::PathTooShort)?; + let peek = path_parts.peek().is_some(); + + match field { + #(#set_path_arms ,)* + _ => Err(miniconf::Error::PathNotFound) + } + } + + fn get_path<'a, P: miniconf::Peekable>( + &self, + path_parts: &'a mut P, + value: &mut [u8] + ) -> Result { + let field = path_parts.next().ok_or(miniconf::Error::PathTooShort)?; + let peek = path_parts.peek().is_some(); + + match field { + #(#get_path_arms ,)* + _ => Err(miniconf::Error::PathNotFound) + } + } + + fn next_path( + state: &mut [usize], + path: &mut miniconf::heapless::String + ) -> Result { + let original_length = path.len(); + loop { + match *state.first().ok_or(miniconf::IterError::PathDepth)? { + #(#next_path_arms ,)* + _ => return Ok(false), + }; + + // Note(unreachable) Without any deferred fields, every arm above returns + #[allow(unreachable_code)] + { + // If a deferred field is done, strip off the field name again, + // and advance to the next field. + path.truncate(original_length); + + state[0] += 1; + state[1..].fill(0); + } + } + } + + fn metadata() -> miniconf::Metadata { + // Loop through all child elements, collecting the maximum length + depth of any + // member. + let mut meta = miniconf::Metadata::default(); + + for index in 0.. { + let item_meta: miniconf::Metadata = match index { + #(#metadata_arms ,)* + _ => break, + }; + + // Note(unreachable) Empty structs break immediatly + #[allow(unreachable_code)] + { + meta.max_length = meta.max_length.max(item_meta.max_length); + meta.max_depth = meta.max_depth.max(item_meta.max_depth); + meta.count += item_meta.count; + } + } + + meta + } + } + } + .into() +} diff --git a/py/miniconf-mqtt/README.md b/py/miniconf-mqtt/README.md index bfbb6274..2d86c9ba 100644 --- a/py/miniconf-mqtt/README.md +++ b/py/miniconf-mqtt/README.md @@ -6,4 +6,4 @@ This directory contains a Python package for interacting with Miniconf utilities Run `pip install .` from this directory to install the `miniconf` package. Alternatively, run `python -m pip install -git+https://github.com/quartiq/miniconf#subdirectory=miniconf-py` to avoid cloning locally. +git+https://github.com/quartiq/miniconf#subdirectory=py/miniconf-mqtt` to avoid cloning locally. diff --git a/py/miniconf-mqtt/miniconf/__init__.py b/py/miniconf-mqtt/miniconf/__init__.py index 112d8c22..a4ea94db 100644 --- a/py/miniconf-mqtt/miniconf/__init__.py +++ b/py/miniconf-mqtt/miniconf/__init__.py @@ -5,7 +5,7 @@ import logging import time -from typing import List +from typing import Set from gmqtt import Client as MqttClient @@ -16,7 +16,7 @@ async def discover( broker: str, prefix_filter: str, discovery_timeout: float = 0.1, - ) -> List[str]: + ) -> Set[str]: """ Get a list of available Miniconf devices. Args: @@ -26,9 +26,9 @@ async def discover( * `discovery_timeout` - The duration to search for clients in seconds. Returns: - A list of discovered client prefixes that match the provided filter. + A set of discovered client prefixes that match the provided filter. """ - discovered_devices = [] + discovered_devices = set() suffix = '/alive' @@ -36,7 +36,7 @@ def handle_message(_client, topic, payload, _qos, _properties): logging.debug('Got message from %s: %s', topic, payload) if json.loads(payload): - discovered_devices.append(topic[:-len(suffix)]) + discovered_devices.add(topic[:-len(suffix)]) client = MqttClient(client_id='') client.on_message = handle_message diff --git a/py/miniconf-mqtt/miniconf/__main__.py b/py/miniconf-mqtt/miniconf/__main__.py index d41b58dc..f638353d 100644 --- a/py/miniconf-mqtt/miniconf/__main__.py +++ b/py/miniconf-mqtt/miniconf/__main__.py @@ -58,8 +58,8 @@ def main(): assert len(devices) == 1, \ f'Multiple miniconf devices found ({devices}). Please specify a more specific --prefix' - logging.info('Automatically using detected device prefix: %s', devices[0]) - prefix = devices[0] + prefix = devices.pop() + logging.info('Automatically using detected device prefix: %s', prefix) async def configure_settings(): interface = await Miniconf.create(prefix, args.broker) diff --git a/py/miniconf-mqtt/miniconf/miniconf.py b/py/miniconf-mqtt/miniconf/miniconf.py index 82b6550c..fc316c90 100644 --- a/py/miniconf-mqtt/miniconf/miniconf.py +++ b/py/miniconf-mqtt/miniconf/miniconf.py @@ -35,12 +35,11 @@ def __init__(self, client, prefix): client: A connected MQTT5 client. prefix: The MQTT toptic prefix of the device to control. """ - self.request_id = 0 self.client = client self.prefix = prefix self.inflight = {} self.client.on_message = self._handle_response - self.response_topic = f'{prefix}/response/{uuid.uuid1().hex}' + self.response_topic = f'{prefix}/response' self.client.subscribe(self.response_topic) def _handle_response(self, _client, topic, payload, _qos, properties): @@ -55,15 +54,18 @@ def _handle_response(self, _client, topic, payload, _qos, properties): """ if topic == self.response_topic: # Extract request_id corrleation data from the properties - request_id = int.from_bytes( - properties['correlation_data'][0], 'big') + request_id = properties['correlation_data'][0] + + if request_id not in self.inflight: + LOGGER.info("Discarding message with CD: %s", request_id) + return self.inflight[request_id].set_result(json.loads(payload)) del self.inflight[request_id] else: LOGGER.warning('Unexpected message on "%s"', topic) - async def command(self, path, value, retain=True): + async def command(self, path, value, retain=False, timeout=5): """Write the provided data to the specified path. Args: @@ -71,6 +73,7 @@ async def command(self, path, value, retain=True): value: The value to write to the path. retain: Retain the MQTT message changing the setting by the broker. + timeout: The maximum time to wait for the response in seconds. Returns: The response to the command as a dictionary. @@ -80,19 +83,18 @@ async def command(self, path, value, retain=True): fut = asyncio.get_running_loop().create_future() # Assign unique correlation data for response dispatch - assert self.request_id not in self.inflight - self.inflight[self.request_id] = fut - correlation_data = self.request_id.to_bytes(4, 'big') - self.request_id += 1 + request_id = uuid.uuid1().hex.encode() + assert request_id not in self.inflight + self.inflight[request_id] = fut - payload = json.dumps(value) - LOGGER.info('Sending "%s" to "%s"', value, topic) + payload = json.dumps(value, separators=(",", ":")) + LOGGER.info('Sending "%s" to "%s" with CD: %s', value, topic, request_id) self.client.publish( topic, payload=payload, qos=0, retain=retain, response_topic=self.response_topic, - correlation_data=correlation_data) + correlation_data=request_id) - result = await fut + result = await asyncio.wait_for(fut, timeout) if result['code'] != 0: raise MiniconfException(result['msg']) diff --git a/src/array.rs b/src/array.rs new file mode 100644 index 00000000..eb6f21b9 --- /dev/null +++ b/src/array.rs @@ -0,0 +1,199 @@ +//! Array support +//! +//! # Design +//! Miniconf supports homogeneous arrays of items contained in structures using two forms. For the +//! [`Array`], each item of the array is accessed as a `Miniconf` tree. +//! +//! For standard arrays of [T; N] form, each item of the array is accessed as one atomic +//! value (i.e. a single Miniconf item). +//! +//! The type you should use depends on what data is contained in your array. If your array contains +//! `Miniconf` items, you can (and often want to) use [`Array`]. However, if each element in your list is +//! individually configurable as a single value (e.g. a list of u32), then you must use a +//! standard [T; N] array. +use super::{Error, IterError, Metadata, Miniconf, Peekable}; + +use core::fmt::Write; + +/// An array that exposes each element through their [`Miniconf`](trait.Miniconf.html) implementation. +#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord, Hash)] +pub struct Array([T; N]); + +impl core::ops::Deref for Array { + type Target = [T; N]; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} +impl core::ops::DerefMut for Array { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl Default for Array { + fn default() -> Self { + Self([T::default(); N]) + } +} + +impl From<[T; N]> for Array { + fn from(x: [T; N]) -> Self { + Self(x) + } +} + +impl From> for [T; N] { + fn from(x: Array) -> Self { + x.0 + } +} + +/// Returns the number of digits required to format an integer less than `x`. +const fn digits(x: usize) -> usize { + let mut n = 10; + let mut num_digits = 1; + + while x > n { + n *= 10; + num_digits += 1; + } + num_digits +} + +impl Miniconf for Array { + fn set_path<'a, P: Peekable>( + &mut self, + path_parts: &'a mut P, + value: &[u8], + ) -> Result { + let i = self.0.index(path_parts.next())?; + + self.0 + .get_mut(i) + .ok_or(Error::BadIndex)? + .set_path(path_parts, value) + } + + fn get_path<'a, P: Peekable>( + &self, + path_parts: &'a mut P, + value: &mut [u8], + ) -> Result { + let i = self.0.index(path_parts.next())?; + + self.0 + .get(i) + .ok_or(Error::BadIndex)? + .get_path(path_parts, value) + } + + fn metadata() -> Metadata { + let mut meta = T::metadata(); + + // Unconditionally account for separator since we add it + // even if elements that are deferred to (`Options`) + // may have no further hierarchy to add and remove the separator again. + meta.max_length += digits(N) + 1; + meta.max_depth += 1; + meta.count *= N; + + meta + } + + fn next_path( + state: &mut [usize], + topic: &mut heapless::String, + ) -> Result { + let original_length = topic.len(); + + while *state.first().ok_or(IterError::PathDepth)? < N { + // Add the array index and separator to the topic name. + write!(topic, "{}/", state[0]).map_err(|_| IterError::PathLength)?; + + if T::next_path(&mut state[1..], topic)? { + return Ok(true); + } + + // Strip off the previously prepended index, since we completed that element and need + // to instead check the next one. + topic.truncate(original_length); + + state[0] += 1; + state[1..].fill(0); + } + + Ok(false) + } +} + +trait IndexLookup { + fn index(&self, next: Option<&str>) -> Result; +} + +impl IndexLookup for [T; N] { + fn index(&self, next: Option<&str>) -> Result { + let next = next.ok_or(Error::PathTooShort)?; + + // Parse what should be the index value + next.parse().map_err(|_| Error::BadIndex) + } +} + +impl Miniconf for [T; N] { + fn set_path<'a, P: Peekable>( + &mut self, + path_parts: &mut P, + value: &[u8], + ) -> Result { + let i = self.index(path_parts.next())?; + + if path_parts.peek().is_some() { + return Err(Error::PathTooLong); + } + + let item = <[T]>::get_mut(self, i).ok_or(Error::BadIndex)?; + let (value, len) = serde_json_core::from_slice(value)?; + *item = value; + Ok(len) + } + + fn get_path<'a, P: Peekable>( + &self, + path_parts: &mut P, + value: &mut [u8], + ) -> Result { + let i = self.index(path_parts.next())?; + + if path_parts.peek().is_some() { + return Err(Error::PathTooLong); + } + + let item = <[T]>::get(self, i).ok_or(Error::BadIndex)?; + Ok(serde_json_core::to_slice(item, value)?) + } + + fn metadata() -> Metadata { + Metadata { + max_length: digits(N), + max_depth: 1, + count: N, + } + } + + fn next_path( + state: &mut [usize], + path: &mut heapless::String, + ) -> Result { + if *state.first().ok_or(IterError::PathDepth)? < N { + // Add the array index to the topic name. + write!(path, "{}", state[0]).map_err(|_| IterError::PathLength)?; + + state[0] += 1; + Ok(true) + } else { + Ok(false) + } + } +} diff --git a/src/iter.rs b/src/iter.rs index 6371c352..01f3e39a 100644 --- a/src/iter.rs +++ b/src/iter.rs @@ -1,25 +1,63 @@ use super::Miniconf; +use core::marker::PhantomData; use heapless::String; -pub struct MiniconfIter<'a, Settings: Miniconf + ?Sized, const TS: usize> { - pub(crate) settings: &'a Settings, - pub(crate) state: &'a mut [usize], +/// An iterator over the paths in a Miniconf namespace. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MiniconfIter { + /// Zero-size marker field to allow being generic over M and gaining access to M. + marker: PhantomData, + + /// The iteration state. + /// + /// It contains the current field/element index at each path hierarchy level + /// and needs to be at least as large as the maximum path depth. + state: [usize; L], + + /// The remaining length of the iterator. + /// + /// It is used to provide an exact and trusted [Iterator::size_hint]. + /// C.f. [core::iter::TrustedLen]. + /// + /// It may be None to indicate unknown length. + count: Option, } -impl<'a, Settings: Miniconf + ?Sized, const TS: usize> Iterator for MiniconfIter<'a, Settings, TS> { +impl Default for MiniconfIter { + fn default() -> Self { + MiniconfIter { + marker: PhantomData, + state: [0; L], + count: None, + } + } +} + +impl MiniconfIter { + pub fn new(count: Option) -> Self { + Self { + count, + ..Default::default() + } + } +} + +impl Iterator for MiniconfIter { type Item = String; fn next(&mut self) -> Option { - let mut topic_buffer: String = String::new(); - - if self - .settings - .recurse_paths(&mut self.state, &mut topic_buffer) - .is_some() - { - Some(topic_buffer) + let mut path = Self::Item::new(); + + if M::next_path(&mut self.state, &mut path).unwrap() { + self.count = self.count.map(|c| c - 1); + Some(path) } else { + debug_assert_eq!(self.count.unwrap_or_default(), 0); None } } + + fn size_hint(&self) -> (usize, Option) { + (self.count.unwrap_or_default(), self.count) + } } diff --git a/src/lib.rs b/src/lib.rs index 097f474a..b9225058 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,99 +1,26 @@ #![no_std] -//! # Miniconf -//! -//! Miniconf is a a lightweight utility to manage run-time configurable settings. It allows -//! access and manipulation of struct fields by assigning each field a unique path-like identifier. -//! -//! ## Overview -//! -//! Miniconf uses a [Derive macro](derive.Miniconf.html) to automatically assign unique paths to -//! each setting. All values are transmitted and received in JSON format. -//! -//! With the derive macro, field values can be easily retrieved or modified using a run-time -//! string. -//! -//! ### Example -//! ``` -//! use miniconf::{Miniconf, MiniconfAtomic}; -//! use serde::{Serialize, Deserialize}; -//! -//! #[derive(Deserialize, Serialize, MiniconfAtomic, Default)] -//! struct Coefficients { -//! forward: f32, -//! backward: f32, -//! } -//! -//! #[derive(Miniconf, Default)] -//! struct Settings { -//! filter: Coefficients, -//! channel_gain: [f32; 2], -//! sample_rate: u32, -//! force_update: bool, -//! } -//! -//! let mut settings = Settings::default(); -//! -//! // Update sample rate. -//! settings.set("sample_rate", b"350").unwrap(); -//! -//! // Update filter coefficients. -//! settings.set("filter", b"{\"forward\": 35.6, \"backward\": 0.0}").unwrap(); -//! -//! // Update channel gain for channel 0. -//! settings.set("channel_gain/0", b"15").unwrap(); -//! -//! // Serialize the current sample rate into the provided buffer. -//! let mut buffer = [0u8; 256]; -//! let len = settings.get("sample_rate", &mut buffer).unwrap(); -//! // `sample_rate`'s serialized value now exists in `buffer[..len]`. -//! ``` -//! -//! ## Features -//! Miniconf supports an MQTT-based client for configuring and managing run-time settings via MQTT. -//! To enable this feature, enable the `mqtt-client` feature. -//! -//! ### Path iteration -//! -//! Miniconf also allows iteration over all settings paths: -//! ```rust -//! use miniconf::Miniconf; -//! -//! #[derive(Default, Miniconf)] -//! struct Settings { -//! sample_rate: u32, -//! update: bool, -//! } -//! -//! let settings = Settings::default(); -//! -//!let mut state = [0; 8]; -//! for topic in settings.into_iter::<128>(&mut state).unwrap() { -//! println!("Discovered topic: `{:?}`", topic); -//! } -//! ``` -//! -//! ## Supported Protocols -//! -//! Miniconf is designed to be protocol-agnostic. Any means that you have of receiving input from -//! some external source can be used to acquire paths and values for updating settings. -//! -//! While Miniconf is platform agnostic, there is an [MQTT-based client](MqttClient) provided to -//! manage settings via the [MQTT protocol](https://mqtt.org). -//! -//! ## Limitations -//! -//! Minconf cannot be used with some of Rust's more complex types. Some unsupported types: -//! * Complex enums -//! * Tuples +#![doc = include_str!("../README.md")] + +mod array; +mod iter; +mod option; + +pub use array::Array; +pub use iter::MiniconfIter; +pub use miniconf_derive::Miniconf; +pub use option::Option; #[cfg(feature = "mqtt-client")] mod mqtt_client; -pub mod iter; - #[cfg(feature = "mqtt-client")] pub use mqtt_client::MqttClient; +// Re-exports +pub use heapless; +pub use serde; +pub use serde_json_core; + #[cfg(feature = "mqtt-client")] pub use minimq; @@ -106,16 +33,9 @@ pub use serde::{ ser::Serialize, }; -pub use serde_json_core; - -pub use derive_miniconf::{Miniconf, MiniconfAtomic}; - -pub use heapless; - -use core::fmt::Write; - -/// Errors that occur during settings configuration -#[derive(Debug, PartialEq)] +/// Errors that can occur when using the [Miniconf] API. +#[non_exhaustive] +#[derive(Debug, Copy, Clone, PartialEq, Eq)] pub enum Error { /// The provided path wasn't found in the structure. /// @@ -132,12 +52,6 @@ pub enum Error { /// Double check the ending and add the remainder of the path. PathTooShort, - /// The path provided refers to a member of a configurable structure, but the structure - /// must be updated all at once. - /// - /// Refactor the request to configure the surrounding structure at once. - AtomicUpdateRequired, - /// The value provided for configuration could not be deserialized into the proper type. /// /// Check that the serialized data is valid JSON and of the correct type. @@ -146,22 +60,29 @@ pub enum Error { /// The value provided could not be serialized. /// /// Check that the buffer had sufficient space. - SerializationFailed, + Serialization(serde_json_core::ser::Error), /// When indexing into an array, the index provided was out of bounds. /// /// Check array indices to ensure that bounds for all paths are respected. BadIndex, + + /// The path does not exist at runtime. + /// + /// This is the case if a deferred [core::option::Option] or [Option] + /// is `None` at runtime. + PathAbsent, } /// Errors that occur during iteration over topic paths. -#[derive(Debug)] +#[non_exhaustive] +#[derive(Debug, Copy, Clone, Eq, PartialEq)] pub enum IterError { /// The provided state vector is not long enough. - InsufficientStateDepth, + PathDepth, /// The provided topic length is not long enough. - InsufficientTopicLength, + PathLength, } impl From for u8 { @@ -170,10 +91,10 @@ impl From for u8 { Error::PathNotFound => 1, Error::PathTooLong => 2, Error::PathTooShort => 3, - Error::AtomicUpdateRequired => 4, Error::Deserialization(_) => 5, Error::BadIndex => 6, - Error::SerializationFailed => 7, + Error::Serialization(_) => 7, + Error::PathAbsent => 8, } } } @@ -184,321 +105,160 @@ impl From for Error { } } -/// Metadata about a settings structure. -pub struct MiniconfMetadata { - /// The maximum length of a topic in the structure. - pub max_topic_size: usize, +impl From for Error { + fn from(err: serde_json_core::ser::Error) -> Error { + Error::Serialization(err) + } +} - /// The maximum recursive depth of the structure. +/// Metadata about a [Miniconf] namespace. +#[non_exhaustive] +#[derive(Default, Debug, Copy, Clone, PartialEq, Eq)] +pub struct Metadata { + /// The maximum length of a path. + pub max_length: usize, + + /// The maximum path depth. pub max_depth: usize, + + /// The number of paths. + pub count: usize, +} + +/// Helper trait for [core::iter::Peekable]. +pub trait Peekable: core::iter::Iterator { + fn peek(&mut self) -> core::option::Option<&Self::Item>; +} + +impl Peekable for core::iter::Peekable { + fn peek(&mut self) -> core::option::Option<&Self::Item> { + core::iter::Peekable::peek(self) + } } +/// Trait exposing serialization/deserialization of elements by path. pub trait Miniconf { - /// Update settings directly from a string path and data. + /// Update an element by path. /// /// # Args - /// * `path` - The path to update within `settings`. - /// * `data` - The serialized data making up the contents of the configured value. + /// * `path` - The path to the element with '/' as the separator. + /// * `data` - The serialized data making up the content. /// /// # Returns - /// The result of the configuration operation. - fn set(&mut self, path: &str, data: &[u8]) -> Result<(), Error> { - self.string_set(path.split('/').peekable(), data) + /// The number of bytes consumed from `data` or an [Error]. + fn set(&mut self, path: &str, data: &[u8]) -> Result { + self.set_path(&mut path.split('/').peekable(), data) } - /// Retrieve a serialized settings value from a string path. + /// Retrieve a serialized value by path. /// /// # Args - /// * `path` - The path to retrieve. - /// * `data` - The location to serialize the data into. + /// * `path` - The path to the element with '/' as the separator. + /// * `data` - The buffer to serialize the data into. /// /// # Returns - /// The number of bytes used in the `data` buffer for serialization. + /// The number of bytes used in the `data` buffer or an [Error]. fn get(&self, path: &str, data: &mut [u8]) -> Result { - self.string_get(path.split('/').peekable(), data) + self.get_path(&mut path.split('/').peekable(), data) } - /// Create an iterator to read all possible settings paths. + /// Create an iterator of all possible paths. /// - /// # Note - /// The state vector can be used to resume iteration from a previous point in time. The data - /// should be zero-initialized if starting iteration for the first time. + /// This is a depth-first walk. + /// The iterator will walk all paths, even those that may be absent at run-time (see [Option]). + /// The iterator has an exact and trusted [Iterator::size_hint]. /// /// # Template Arguments - /// * `TS` - The maximum number of bytes to encode a settings path into. + /// * `L` - The maximum depth of the path, i.e. number of separators plus 1. + /// * `TS` - The maximum length of the path in bytes. /// - /// # Args - /// * `state` - A state vector to record iteration state in. - fn into_iter<'a, const TS: usize>( - &'a self, - state: &'a mut [usize], - ) -> Result, IterError> { - let metadata = self.get_metadata(); - - if TS < metadata.max_topic_size { - return Err(IterError::InsufficientTopicLength); + /// # Returns + /// A [MiniconfIter] of paths or an [IterError] if `L` or `TS` are insufficient. + fn iter_paths( + ) -> Result, IterError> { + let meta = Self::metadata(); + + if TS < meta.max_length { + return Err(IterError::PathLength); } - if state.len() < metadata.max_depth { - return Err(IterError::InsufficientStateDepth); + if L < meta.max_depth { + return Err(IterError::PathDepth); } - Ok(iter::MiniconfIter { - settings: self, - state, - }) + Ok(Self::unchecked_iter_paths(Some(meta.count))) } - /// Create an iterator to read all possible settings paths. + /// Create an iterator of all possible paths. + /// + /// This is a depth-first walk. + /// It will return all paths, even those that may be absent at run-time. /// /// # Note - /// This does not check that the topic size or state vector are large enough. If they are not, + /// This does not check that the path size or state vector are large enough. If they are not, /// panics may be generated internally by the library. /// - /// # Note - /// The state vector can be used to resume iteration from a previous point in time. The data - /// should be zero-initialized if starting iteration for the first time. + /// # Args + /// * `count`: Optional iterator length if known. /// /// # Template Arguments - /// * `TS` - The maximum number of bytes to encode a settings path into. - /// - /// # Args - /// * `state` - A state vector to record iteration state in. - fn unchecked_into_iter<'a, const TS: usize>( - &'a self, - state: &'a mut [usize], - ) -> iter::MiniconfIter<'a, Self, TS> { - iter::MiniconfIter { - settings: self, - state, - } + /// * `L` - The maximum depth of the path, i.e. number of separators plus 1. + /// * `TS` - The maximum length of the path in bytes. + fn unchecked_iter_paths( + count: core::option::Option, + ) -> iter::MiniconfIter { + iter::MiniconfIter::new(count) } - fn string_set( + /// Deserialize an element by path. + /// + /// # Args + /// * `path_parts`: A `Peekable` `Iterator` identifying the element. + /// * `value`: A slice containing the data to be deserialized. + /// + /// # Returns + /// The number of bytes consumed from `value` or an `Error`. + fn set_path<'a, P: Peekable>( &mut self, - topic_parts: core::iter::Peekable>, + path_parts: &'a mut P, value: &[u8], - ) -> Result<(), Error>; - - fn string_get( - &self, - topic_parts: core::iter::Peekable>, - value: &mut [u8], ) -> Result; - /// Get metadata about the settings structure. - fn get_metadata(&self) -> MiniconfMetadata; - - fn recurse_paths( - &self, - index: &mut [usize], - topic: &mut heapless::String, - ) -> Option<()>; -} - -macro_rules! impl_single { - ($x:ty) => { - impl Miniconf for $x { - fn string_set( - &mut self, - mut topic_parts: core::iter::Peekable>, - value: &[u8], - ) -> Result<(), Error> { - if topic_parts.peek().is_some() { - return Err(Error::PathTooLong); - } - *self = serde_json_core::from_slice(value)?.0; - Ok(()) - } - - fn string_get( - &self, - mut topic_parts: core::iter::Peekable>, - value: &mut [u8], - ) -> Result { - if topic_parts.peek().is_some() { - return Err(Error::PathTooLong); - } - - serde_json_core::to_slice(self, value).map_err(|_| Error::SerializationFailed) - } - - fn get_metadata(&self) -> MiniconfMetadata { - MiniconfMetadata { - // No topic length is needed, as there are no sub-members. - max_topic_size: 0, - // One index is required for the current element. - max_depth: 1, - } - } - - // This implementation is the base case for primitives where it will - // yield once for self, then return None on subsequent calls. - fn recurse_paths( - &self, - index: &mut [usize], - _topic: &mut heapless::String, - ) -> Option<()> { - if index.len() == 0 { - // Note: During expected execution paths using `into_iter()`, the size of the - // index stack is checked in advance to make sure this condition doesn't occur. - // However, it's possible to happen if the user manually calls `recurse_paths`. - unreachable!("Index stack too small"); - } - - let i = index[0]; - index[0] += 1; - index[1..].iter_mut().for_each(|x| *x = 0); - - if i == 0 { - Some(()) - } else { - None - } - } - } - }; -} - -impl Miniconf for [T; N] { - fn string_set( - &mut self, - mut topic_parts: core::iter::Peekable>, - value: &[u8], - ) -> Result<(), Error> { - let next = topic_parts.next(); - if next.is_none() { - return Err(Error::PathTooShort); - } - - // Parse what should be the index value - let i: usize = serde_json_core::from_str(next.unwrap()) - .or(Err(Error::BadIndex))? - .0; - - if i >= self.len() { - return Err(Error::BadIndex); - } - - self[i].string_set(topic_parts, value)?; - - Ok(()) - } - - fn string_get( + /// Serialize an element by path. + /// + /// # Args + /// * `path_parts`: A `Peekable` `Iterator` identifying the element. + /// * `value`: A slice for the value to be serialized into. + /// + /// # Returns + /// The number of bytes written to `value` or an `Error`. + fn get_path<'a, P: Peekable>( &self, - mut topic_parts: core::iter::Peekable>, + path_parts: &'a mut P, value: &mut [u8], - ) -> Result { - let next = topic_parts.next(); - if next.is_none() { - return Err(Error::PathTooShort); - } - - // Parse what should be the index value - let i: usize = serde_json_core::from_str(next.unwrap()) - .or(Err(Error::BadIndex))? - .0; - - if i >= self.len() { - return Err(Error::BadIndex); - } - - self[i].string_get(topic_parts, value) - } - - fn get_metadata(&self) -> MiniconfMetadata { - // First, figure out how many digits the maximum index requires when printing. - let mut index = N - 1; - let mut num_digits = 0; - - while index > 0 { - index /= 10; - num_digits += 1; - } - - let metadata = self[0].get_metadata(); - - // If the sub-members have topic size, we also need to include an additional character for - // the path separator. This is ommitted if the sub-members have no topic (e.g. fundamental - // types, enums). - if metadata.max_topic_size > 0 { - MiniconfMetadata { - max_topic_size: metadata.max_topic_size + num_digits + 1, - max_depth: metadata.max_depth + 1, - } - } else { - MiniconfMetadata { - max_topic_size: num_digits, - max_depth: metadata.max_depth + 1, - } - } - } - - fn recurse_paths( - &self, - index: &mut [usize], - topic: &mut heapless::String, - ) -> Option<()> { - let original_length = topic.len(); - - if index.len() == 0 { - // Note: During expected execution paths using `into_iter()`, the size of the - // index stack is checked in advance to make sure this condition doesn't occur. - // However, it's possible to happen if the user manually calls `recurse_paths`. - unreachable!("Index stack too small"); - } - - while index[0] < N { - // Add the array index to the topic name. - if topic.len() > 0 { - if topic.push('/').is_err() { - // Note: During expected execution paths using `into_iter()`, the size of the - // topic buffer is checked in advance to make sure this condition doesn't occur. - // However, it's possible to happen if the user manually calls `recurse_paths`. - unreachable!("Topic buffer too short"); - } - } - - if write!(topic, "{}", index[0]).is_err() { - // Note: During expected execution paths using `into_iter()`, the size of the - // topic buffer is checked in advance to make sure this condition doesn't occur. - // However, it's possible to happen if the user manually calls `recurse_paths`. - unreachable!("Topic buffer too short"); - } - - if self[index[0]] - .recurse_paths(&mut index[1..], topic) - .is_some() - { - return Some(()); - } - - // Strip off the previously prepended index, since we completed that element and need - // to instead check the next one. - topic.truncate(original_length); - - index[0] += 1; - index[1..].iter_mut().for_each(|x| *x = 0); - } + ) -> Result; - None - } + /// Get the next path in the namespace. + /// + /// This is usually not called directly but through a [MiniconfIter] returned by [Miniconf::iter_paths]. + /// + /// # Args + /// * `state`: A state array indicating the path to be retrieved. + /// A zeroed vector indicates the first path. The vector is advanced + /// such that the next element will be retrieved when called again. + /// The array needs to be at least as long as the maximum path depth. + /// * `path`: A string to write the path into. + /// + /// # Returns + /// A `bool` indicating a valid path was written to `path` from the given `state`. + /// If `false`, `path` is invalid and there are no more paths within `self` at and + /// beyond `state`. + /// May return `IterError` indicating insufficient `state` or `path` size. + fn next_path( + state: &mut [usize], + path: &mut heapless::String, + ) -> Result; + + /// Get metadata about the paths in the namespace. + fn metadata() -> Metadata; } - -// Implement trait for the primitive types -impl_single!(u8); -impl_single!(u16); -impl_single!(u32); -impl_single!(u64); - -impl_single!(i8); -impl_single!(i16); -impl_single!(i32); -impl_single!(i64); - -impl_single!(f32); -impl_single!(f64); - -impl_single!(usize); -impl_single!(bool); diff --git a/src/mqtt_client/messages.rs b/src/mqtt_client/messages.rs deleted file mode 100644 index ac232ce6..00000000 --- a/src/mqtt_client/messages.rs +++ /dev/null @@ -1,84 +0,0 @@ -use crate::Error; -use core::fmt::Write; -use heapless::{String, Vec}; -use serde::Serialize; - -/// The payload of the MQTT response message to a settings update request. -#[derive(Serialize)] -pub struct SettingsResponse { - code: u8, - msg: String<64>, -} - -impl From> for SettingsResponse { - fn from(result: Result<(), Error>) -> Self { - match result { - Ok(_) => Self { - msg: String::from("OK"), - code: 0, - }, - - Err(error) => { - let mut msg = String::new(); - if write!(&mut msg, "{:?}", error).is_err() { - msg = String::from("Miniconf Error"); - } - - Self { - code: error.into(), - msg, - } - } - } - } -} - -/// Represents a generic MQTT message. -pub struct MqttMessage<'a> { - pub topic: &'a str, - pub message: Vec, - pub properties: Vec, 1>, -} - -impl<'a> MqttMessage<'a> { - /// Construct a new MQTT message from an incoming message. - /// - /// # Args - /// * `properties` - A list of properties associated with the inbound message. - /// * `default_response` - The default response topic for the message - /// * `msg` - The response associated with the message. Must fit within 128 bytes. - pub fn new<'b: 'a>( - properties: &[minimq::Property<'a>], - default_response: &'b str, - msg: &impl Serialize, - ) -> Self { - // Extract the MQTT response topic. - let topic = properties - .iter() - .find_map(|prop| { - if let minimq::Property::ResponseTopic(topic) = prop { - Some(topic) - } else { - None - } - }) - .unwrap_or(&default_response); - - // Associate any provided correlation data with the response. - let mut correlation_data: Vec, 1> = Vec::new(); - if let Some(data) = properties - .iter() - .find(|prop| matches!(prop, minimq::Property::CorrelationData(_))) - { - // Note(unwrap): Unwrap can not fail, as we only ever push one value. - correlation_data.push(*data).unwrap(); - } - - Self { - topic, - // Note(unwrap): All SettingsResponse objects are guaranteed to fit in the vector. - message: serde_json_core::to_vec(msg).unwrap(), - properties: correlation_data, - } - } -} diff --git a/src/mqtt_client/mod.rs b/src/mqtt_client/mod.rs index 3a664003..591c7cf1 100644 --- a/src/mqtt_client/mod.rs +++ b/src/mqtt_client/mod.rs @@ -1,3 +1,466 @@ -mod messages; -mod mqtt_client; -pub use mqtt_client::MqttClient; +use serde::Serialize; +use serde_json_core::heapless::{String, Vec}; + +use crate::Miniconf; +use log::info; +use minimq::{ + embedded_nal::{IpAddr, TcpClientStack}, + embedded_time, + types::{SubscriptionOptions, TopicFilter}, + Publication, QoS, Retain, +}; + +use core::fmt::Write; + +// The maximum topic length of any settings path. +const MAX_TOPIC_LENGTH: usize = 128; + +// The keepalive interval to use for MQTT in seconds. +const KEEPALIVE_INTERVAL_SECONDS: u16 = 60; + +// The maximum recursive depth of a settings structure. +const MAX_RECURSION_DEPTH: usize = 8; + +// The delay after not receiving messages after initial connection that settings will be +// republished. +const REPUBLISH_TIMEOUT_SECONDS: u32 = 2; + +type MiniconfIter = crate::MiniconfIter; + +mod sm { + use minimq::embedded_time::{self, duration::Extensions, Instant}; + use smlang::statemachine; + + statemachine! { + transitions: { + *Initial + Connected = ConnectedToBroker, + ConnectedToBroker + IndicatedLife = PendingSubscribe, + + // After initial subscriptions, we start a timeout to republish all settings. + PendingSubscribe + Subscribed / start_republish_timeout = PendingRepublish, + + // Settings republish can be completed any time after subscription. + PendingRepublish + StartRepublish / start_republish = RepublishingSettings, + RepublishingSettings + StartRepublish / start_republish = RepublishingSettings, + Active + StartRepublish / start_republish = RepublishingSettings, + + // After republishing settings, we are in an idle "active" state. + RepublishingSettings + RepublishComplete = Active, + + // All states transition back to `initial` on reset. + _ + Reset = Initial, + } + } + + pub struct Context { + clock: C, + timeout: Option>, + pub republish_state: super::MiniconfIter, + } + + impl Context { + pub fn new(clock: C) -> Self { + Self { + clock, + timeout: None, + republish_state: Default::default(), + } + } + + pub fn republish_has_timed_out(&self) -> bool { + if let Some(timeout) = self.timeout { + self.clock.try_now().unwrap() > timeout + } else { + false + } + } + } + + impl StateMachineContext for Context { + fn start_republish_timeout(&mut self) { + self.timeout.replace( + self.clock.try_now().unwrap() + super::REPUBLISH_TIMEOUT_SECONDS.seconds(), + ); + } + + fn start_republish(&mut self) { + self.republish_state = Default::default(); + } + } +} + +/// MQTT settings interface. +/// +/// # Design +/// The MQTT client places the [Miniconf] paths `` at the MQTT `/settings/` topic, +/// where `` is provided in the client constructor. +/// +/// It publishes its alive-ness as a `1` to `/alive` and sets a will to publish `0` there when +/// it is disconnected. +/// +/// # Limitations +/// The MQTT client logs failures to subscribe to the settings topic, but does not re-attempt to +/// connect to it when errors occur. +/// +/// The client only supports paths up to 128 byte length and maximum depth of 8. +/// Keepalive interval and re-publication timeout are fixed to 60 and 2 seconds respectively. +/// +/// # Example +/// ``` +/// use miniconf::{MqttClient, Miniconf}; +/// +/// #[derive(Miniconf, Clone, Default)] +/// struct Settings { +/// foo: bool, +/// } +/// +/// let mut client: MqttClient = MqttClient::new( +/// std_embedded_nal::Stack::default(), +/// "", // client_id auto-assign +/// "quartiq/application/12345", // prefix +/// "127.0.0.1".parse().unwrap(), +/// std_embedded_time::StandardClock::default(), +/// Settings::default(), +/// ) +/// .unwrap(); +/// +/// client.handled_update(|path, old_settings, new_settings| { +/// if new_settings.foo { +/// return Err("Foo!"); +/// } +/// *old_settings = new_settings.clone(); +/// Ok(()) +/// }).unwrap(); +/// ``` +pub struct MqttClient +where + Settings: Miniconf + Clone, + Stack: TcpClientStack, + Clock: embedded_time::Clock, +{ + mqtt: minimq::Minimq, + settings: Settings, + state: sm::StateMachine>, + settings_prefix: String, + prefix: String, +} + +impl + MqttClient +where + Settings: Miniconf + Clone, + Stack: TcpClientStack, + Clock: embedded_time::Clock + Clone, +{ + /// Construct a new MQTT settings interface. + /// + /// # Args + /// * `stack` - The network stack to use for communication. + /// * `client_id` - The ID of the MQTT client. May be an empty string for auto-assigning. + /// * `prefix` - The MQTT device prefix to use for this device. + /// * `broker` - The IP address of the MQTT broker to use. + /// * `clock` - The clock for managing the MQTT connection. + /// * `settings` - The initial settings values. + pub fn new( + stack: Stack, + client_id: &str, + prefix: &str, + broker: IpAddr, + clock: Clock, + settings: Settings, + ) -> Result> { + let mut mqtt = minimq::Minimq::new(broker, client_id, stack, clock.clone())?; + + // Note(unwrap): The client was just created, so it's valid to set a keepalive interval + // now, since we're not yet connected to the broker. + mqtt.client() + .set_keepalive_interval(KEEPALIVE_INTERVAL_SECONDS) + .unwrap(); + + // Configure a will so that we can indicate whether or not we are connected. + let mut connection_topic: String = String::from(prefix); + connection_topic.push_str("/alive").unwrap(); + mqtt.client() + .set_will( + &connection_topic, + "0".as_bytes(), + QoS::AtMostOnce, + Retain::Retained, + &[], + ) + .unwrap(); + + let mut settings_prefix: String = String::from(prefix); + settings_prefix.push_str("/settings").unwrap(); + + assert!(settings_prefix.len() + 1 + Settings::metadata().max_length <= MAX_TOPIC_LENGTH); + + Ok(Self { + mqtt, + state: sm::StateMachine::new(sm::Context::new(clock)), + settings, + settings_prefix, + prefix: String::from(prefix), + }) + } + + fn handle_republish(&mut self) { + if !self.mqtt.client().can_publish(QoS::AtMostOnce) { + return; + } + + for topic in &mut self.state.context_mut().republish_state { + let mut data = [0; MESSAGE_SIZE]; + + // Note: The topic may be absent at runtime (`miniconf::Option` or deferred `Option`). + let len = match self.settings.get(&topic, &mut data) { + Err(crate::Error::PathAbsent) => continue, + Ok(len) => len, + e => e.unwrap(), + }; + + let mut prefixed_topic: String = String::new(); + write!(&mut prefixed_topic, "{}/{}", &self.settings_prefix, &topic).unwrap(); + + // Note(unwrap): This should not fail because `can_publish()` was checked before + // attempting this publish. + self.mqtt + .client() + .publish( + Publication::new(&data[..len]) + .topic(&prefixed_topic) + .finish() + .unwrap(), + ) + .unwrap(); + + // If we can't publish any more messages, bail out now to prevent the iterator from + // progressing. If we don't bail out now, we'd silently drop a setting. + if !self.mqtt.client().can_publish(QoS::AtMostOnce) { + return; + } + } + + // If we got here, we completed iterating over the topics and published them all. + self.state + .process_event(sm::Events::RepublishComplete) + .unwrap(); + } + + fn handle_subscription(&mut self) { + log::info!("MQTT connected, subscribing to settings"); + + // Note(unwrap): We construct a string with two more characters than the prefix + // structure, so we are guaranteed to have space for storage. + let mut settings_topic: String = + String::from(self.settings_prefix.as_str()); + settings_topic.push_str("/#").unwrap(); + + let topic_filter = TopicFilter::new(&settings_topic) + .options(SubscriptionOptions::default().ignore_local_messages()); + + if self.mqtt.client().subscribe(&[topic_filter], &[]).is_ok() { + self.state.process_event(sm::Events::Subscribed).unwrap(); + } + } + + fn handle_indicating_alive(&mut self) { + // Publish a connection status message. + let mut connection_topic: String = String::from(self.prefix.as_str()); + connection_topic.push_str("/alive").unwrap(); + + if self + .mqtt + .client() + .publish( + Publication::new("1".as_bytes()) + .topic(&connection_topic) + .retain() + .finish() + .unwrap(), + ) + .is_ok() + { + self.state.process_event(sm::Events::IndicatedLife).unwrap(); + } + } + + /// Update the MQTT interface and service the network. Pass any settings changes to the handler + /// supplied. + /// + /// # Args + /// * `handler` - A closure called with updated settings that can be used to apply current + /// settings or validate the configuration. Arguments are (path, old_settings, new_settings). + /// + /// # Returns + /// True if the settings changed. False otherwise. + pub fn handled_update(&mut self, handler: F) -> Result> + where + F: FnMut(&str, &mut Settings, &Settings) -> Result<(), E>, + E: AsRef, + { + if !self.mqtt.client().is_connected() { + // Note(unwrap): It's always safe to reset. + self.state.process_event(sm::Events::Reset).unwrap(); + } + + match *self.state.state() { + sm::States::Initial => { + if self.mqtt.client().is_connected() { + self.state.process_event(sm::Events::Connected).unwrap(); + } + } + sm::States::ConnectedToBroker => self.handle_indicating_alive(), + sm::States::PendingSubscribe => self.handle_subscription(), + sm::States::PendingRepublish => { + if self.state.context().republish_has_timed_out() { + self.state + .process_event(sm::Events::StartRepublish) + .unwrap(); + } + } + sm::States::RepublishingSettings => self.handle_republish(), + + // Nothing to do in the active state. + sm::States::Active => {} + } + + // All states must handle MQTT traffic. + self.handle_mqtt_traffic(handler) + } + + fn handle_mqtt_traffic( + &mut self, + mut handler: F, + ) -> Result> + where + F: FnMut(&str, &mut Settings, &Settings) -> Result<(), E>, + E: AsRef, + { + let settings = &mut self.settings; + let mqtt = &mut self.mqtt; + let prefix = self.settings_prefix.as_str(); + + let mut response_topic: String = String::from(self.prefix.as_str()); + response_topic.push_str("/log").unwrap(); + let default_response_topic = response_topic.as_str(); + + let mut updated = false; + match mqtt.poll(|client, topic, message, properties| { + let path = match topic.strip_prefix(prefix) { + // For paths, we do not want to include the leading slash. + Some(path) => { + if !path.is_empty() { + &path[1..] + } else { + path + } + } + None => { + info!("Unexpected MQTT topic: {}", topic); + return; + } + }; + + let mut new_settings = settings.clone(); + let message: SettingsResponse = match new_settings.set(path, message) { + Ok(_) => { + updated = true; + handler(path, settings, &new_settings).into() + } + err => { + let mut msg = String::new(); + if write!(&mut msg, "{:?}", err).is_err() { + msg = String::from("Configuration Error"); + } + + SettingsResponse::error(msg) + } + }; + + // Note(unwrap): All SettingsResponse objects are guaranteed to fit in the vector. + let message: Vec = serde_json_core::to_vec(&message).unwrap(); + + client + .publish( + minimq::Publication::new(&message) + .topic(default_response_topic) + .reply(properties) + .qos(QoS::AtLeastOnce) + .finish() + .unwrap(), + ) + .ok(); + }) { + Ok(_) => Ok(updated), + Err(minimq::Error::SessionReset) => { + log::warn!("Settings MQTT session reset"); + self.state.process_event(sm::Events::Reset).unwrap(); + Ok(false) + } + Err(other) => Err(other), + } + } + + /// Update the settings from the network stack without any specific handling. + /// + /// # Returns + /// True if the settings changed. False otherwise + pub fn update(&mut self) -> Result> { + self.handled_update(|_, old, new| { + *old = new.clone(); + Result::<(), &'static str>::Ok(()) + }) + } + + /// Get the current settings from miniconf. + pub fn settings(&self) -> &Settings { + &self.settings + } + + /// Force republication of the current settings. + /// + /// # Note + /// This is intended to be used if modification of a setting had side effects that affected + /// another setting. + pub fn force_republish(&mut self) { + self.state.process_event(sm::Events::StartRepublish).ok(); + } +} + +/// The payload of the MQTT response message to a settings update request. +#[derive(Serialize)] +pub struct SettingsResponse { + code: u8, + msg: String<64>, +} + +impl SettingsResponse { + pub fn ok() -> Self { + Self { + msg: String::from("OK"), + code: 0, + } + } + + pub fn error(msg: String<64>) -> Self { + Self { code: 255, msg } + } +} + +impl> From> for SettingsResponse { + fn from(result: Result) -> Self { + match result { + Ok(_) => SettingsResponse::ok(), + + Err(error) => { + let mut msg = String::new(); + if msg.push_str(error.as_ref()).is_err() { + msg = String::from("Configuration Error"); + } + + Self::error(msg) + } + } + } +} diff --git a/src/mqtt_client/mqtt_client.rs b/src/mqtt_client/mqtt_client.rs deleted file mode 100644 index ed16abcd..00000000 --- a/src/mqtt_client/mqtt_client.rs +++ /dev/null @@ -1,366 +0,0 @@ -/// MQTT-based Run-time Settings Client -/// -/// # Design -/// The MQTT client places all settings paths behind a `/settings/` path prefix, where -/// `` is provided in the client constructor. This prefix is then stripped away to get the -/// settings path for [Miniconf]. -/// -/// ## Example -/// With an MQTT client prefix of `dt/sinara/stabilizer` and a settings path of `adc/0/gain`, the -/// full MQTT path would be `dt/sinara/stabilizer/settings/adc/0/gain`. -/// -/// # Limitations -/// The MQTT client logs failures to subscribe to the settings topic, but does not re-attempt to -/// connect to it when errors occur. -/// -/// Responses to settings updates are sent without quality-of-service guarantees, so there's no -/// guarantee that the requestee will be informed that settings have been applied. -/// -/// The library only supports serialized settings up to 256 bytes currently. -use serde_json_core::heapless::String; - -use minimq::embedded_nal::{IpAddr, TcpClientStack}; - -use super::messages::{MqttMessage, SettingsResponse}; -use crate::Miniconf; -use log::info; -use minimq::{embedded_time, QoS, Retain}; - -use core::fmt::Write; - -// The maximum topic length of any settings path. -const MAX_TOPIC_LENGTH: usize = 128; - -// The keepalive interval to use for MQTT in seconds. -const KEEPALIVE_INTERVAL_SECONDS: u16 = 60; - -// The maximum recursive depth of a settings structure. -const MAX_RECURSION_DEPTH: usize = 8; - -// The delay after not receiving messages after initial connection that settings will be -// republished. -const REPUBLISH_TIMEOUT_SECONDS: u32 = 2; - -mod sm { - use minimq::embedded_time::{self, duration::Extensions, Instant}; - use smlang::statemachine; - - statemachine! { - transitions: { - *Initial + Connected = ConnectedToBroker, - ConnectedToBroker + Subscribed = IndicatingLife, - IndicatingLife + IndicatedAlive / start_republish_timeout = PendingRepublish, - PendingRepublish + MessageReceived / start_republish_timeout = PendingRepublish, - PendingRepublish + RepublishTimeout / start_republish = RepublishingSettings, - RepublishingSettings + RepublishComplete = Active, - - // All states transition back to `initial` on reset. - Initial + Reset = Initial, - ConnectedToBroker + Reset = Initial, - IndicatingLife + Reset = Initial, - PendingRepublish + Reset = Initial, - RepublishingSettings + Reset = Initial, - Active + Reset = Initial, - } - } - - pub struct Context { - clock: C, - timeout: Option>, - pub republish_state: [usize; super::MAX_RECURSION_DEPTH], - } - - impl Context { - pub fn new(clock: C) -> Self { - Self { - clock, - timeout: None, - republish_state: [0; super::MAX_RECURSION_DEPTH], - } - } - - pub fn republish_has_timed_out(&self) -> bool { - if let Some(timeout) = self.timeout { - self.clock.try_now().unwrap() > timeout - } else { - false - } - } - } - - impl StateMachineContext for Context { - fn start_republish_timeout(&mut self) { - self.timeout.replace( - self.clock.try_now().unwrap() + super::REPUBLISH_TIMEOUT_SECONDS.seconds(), - ); - } - - fn start_republish(&mut self) { - self.republish_state = [0; super::MAX_RECURSION_DEPTH]; - } - } -} - -/// MQTT settings interface. -pub struct MqttClient -where - Settings: Miniconf + Default, - Stack: TcpClientStack, - Clock: embedded_time::Clock, -{ - mqtt: minimq::Minimq, - settings: Settings, - state: sm::StateMachine>, - settings_prefix: String, - prefix: String, -} - -impl - MqttClient -where - Settings: Miniconf + Default, - Stack: TcpClientStack, - Clock: embedded_time::Clock + Clone, -{ - /// Construct a new MQTT settings interface. - /// - /// # Args - /// * `stack` - The network stack to use for communication. - /// * `client_id` - The ID of the MQTT client. May be an empty string for auto-assigning. - /// * `prefix` - The MQTT device prefix to use for this device. - /// * `broker` - The IP address of the MQTT broker to use. - /// * `clock` - The clock for managing the MQTT connection. - pub fn new( - stack: Stack, - client_id: &str, - prefix: &str, - broker: IpAddr, - clock: Clock, - ) -> Result> { - // Check the settings topic length. - let settings = Settings::default(); - - let mut mqtt = minimq::Minimq::new(broker, client_id, stack, clock.clone())?; - - // Note(unwrap): The client was just created, so it's valid to set a keepalive interval - // now, since we're not yet connected to the broker. - mqtt.client - .set_keepalive_interval(KEEPALIVE_INTERVAL_SECONDS) - .unwrap(); - - // Configure a will so that we can indicate whether or not we are connected. - let mut connection_topic: String = String::from(prefix); - connection_topic.push_str("/alive").unwrap(); - mqtt.client - .set_will( - &connection_topic, - "0".as_bytes(), - QoS::AtMostOnce, - Retain::Retained, - &[], - ) - .unwrap(); - - let mut settings_prefix: String = String::from(prefix); - settings_prefix.push_str("/settings").unwrap(); - - assert!( - settings_prefix.len() + 1 + settings.get_metadata().max_topic_size <= MAX_TOPIC_LENGTH - ); - - Ok(Self { - mqtt, - state: sm::StateMachine::new(sm::Context::new(clock)), - settings, - settings_prefix, - prefix: String::from(prefix), - }) - } - - fn handle_republish(&mut self) { - if !self.mqtt.client.can_publish(QoS::AtMostOnce) { - return; - } - - for topic in self - .settings - .into_iter::(&mut self.state.context_mut().republish_state) - .unwrap() - { - let mut data = [0; MESSAGE_SIZE]; - - // Note(unwrap): We know this topic exists already because we just got it from the - // iterator. - let len = self.settings.get(&topic, &mut data).unwrap(); - - let mut prefixed_topic: String = String::new(); - write!(&mut prefixed_topic, "{}/{}", &self.settings_prefix, &topic).unwrap(); - - // Note(unwrap): This should not fail because `can_publish()` was checked before - // attempting this publish. - self.mqtt - .client - .publish( - &prefixed_topic, - &data[..len], - QoS::AtMostOnce, - Retain::NotRetained, - &[], - ) - .unwrap(); - - // If we can't publish any more messages, bail out now to prevent the iterator from - // progressing. If we don't bail out now, we'd silently drop a setting. - if !self.mqtt.client.can_publish(QoS::AtMostOnce) { - return; - } - } - - // If we got here, we completed iterating over the topics and published them all. - self.state - .process_event(sm::Events::RepublishComplete) - .unwrap(); - } - - fn handle_subscription(&mut self) { - log::info!("MQTT connected, subscribing to settings"); - - // Note(unwrap): We construct a string with two more characters than the prefix - // structure, so we are guaranteed to have space for storage. - let mut settings_topic: String = - String::from(self.settings_prefix.as_str()); - settings_topic.push_str("/#").unwrap(); - - if self.mqtt.client.subscribe(&settings_topic, &[]).is_ok() { - self.state.process_event(sm::Events::Subscribed).unwrap(); - } - } - - fn handle_indicating_alive(&mut self) { - // Publish a connection status message. - let mut connection_topic: String = String::from(self.prefix.as_str()); - connection_topic.push_str("/alive").unwrap(); - - if self - .mqtt - .client - .publish( - &connection_topic, - "1".as_bytes(), - QoS::AtMostOnce, - Retain::Retained, - &[], - ) - .is_ok() - { - self.state - .process_event(sm::Events::IndicatedAlive) - .unwrap(); - } - } - - /// Update the MQTT interface and service the network - /// - /// # Returns - /// True if the settings changed. False otherwise. - pub fn update(&mut self) -> Result> { - if !self.mqtt.client.is_connected() { - // Note(unwrap): It's always safe to reset. - self.state.process_event(sm::Events::Reset).unwrap(); - } - - match self.state.state() { - &sm::States::Initial => { - if self.mqtt.client.is_connected() { - self.state.process_event(sm::Events::Connected).unwrap(); - } - } - &sm::States::ConnectedToBroker => self.handle_subscription(), - &sm::States::IndicatingLife => self.handle_indicating_alive(), - &sm::States::PendingRepublish => { - if self.state.context().republish_has_timed_out() { - self.state - .process_event(sm::Events::RepublishTimeout) - .unwrap(); - } - } - &sm::States::RepublishingSettings => self.handle_republish(), - - // Nothing to do in the active state. - &sm::States::Active => {} - } - - // All states must handle MQTT traffic. - self.handle_mqtt_traffic() - } - - fn handle_mqtt_traffic(&mut self) -> Result> { - let settings = &mut self.settings; - let mqtt = &mut self.mqtt; - let prefix = self.settings_prefix.as_str(); - let state = &mut self.state; - - let mut response_topic: String = String::from(self.prefix.as_str()); - response_topic.push_str("/log").unwrap(); - let default_response_topic = response_topic.as_str(); - - let mut update = false; - match mqtt.poll(|client, topic, message, properties| { - let path = match topic.strip_prefix(prefix) { - // For paths, we do not want to include the leading slash. - Some(path) => { - if !path.is_empty() { - &path[1..] - } else { - path - } - } - None => { - info!("Unexpected MQTT topic: {}", topic); - return; - } - }; - - log::info!("Settings update: `{}`", path); - - let message: SettingsResponse = settings - .string_set(path.split('/').peekable(), message) - .map(|_| { - update = true; - }) - .into(); - - let response = MqttMessage::new(properties, default_response_topic, &message); - - // We only care about these events in one state, so ignore the errors for the other - // states. - state.process_event(sm::Events::MessageReceived).ok(); - - client - .publish( - response.topic, - &response.message, - // TODO: When Minimq supports more QoS levels, this should be increased to - // ensure that the client has received it at least once. - QoS::AtMostOnce, - Retain::NotRetained, - &response.properties, - ) - .ok(); - }) { - // If settings updated, - Ok(_) => Ok(update), - Err(minimq::Error::SessionReset) => { - log::warn!("Settings MQTT session reset"); - self.state.process_event(sm::Events::Reset).unwrap(); - Ok(false) - } - Err(other) => Err(other), - } - } - - /// Get the current settings from miniconf. - pub fn settings(&self) -> &Settings { - &self.settings - } -} diff --git a/src/option.rs b/src/option.rs new file mode 100644 index 00000000..cdc9342f --- /dev/null +++ b/src/option.rs @@ -0,0 +1,154 @@ +//! Option Support +//! +//! # Design +//! +//! Miniconf supports optional values in two forms. The first for is the [`Option`] type. If the +//! `Option` is `None`, the part of the namespace does not exist at run-time. +//! It will not be iterated over and cannot be `get()` or `set()` using the Miniconf API. +//! +//! This is intended as a mechanism to provide run-time construction of the namespace. In some +//! cases, run-time detection may indicate that some component is not present. In this case, +//! namespaces will not be exposed for it. +//! +//! +//! # Standard Options +//! +//! Miniconf also allows for the normal usage of Rust `Option` types. In this case, the `Option` +//! can be used to atomically access the nullable content within. + +use super::{Error, IterError, Metadata, Miniconf, Peekable}; + +/// An `Option` that exposes its value through their [`Miniconf`](trait.Miniconf.html) implementation. +#[derive( + Clone, + Copy, + Default, + PartialEq, + Eq, + Debug, + PartialOrd, + Ord, + Hash, + serde::Serialize, + serde::Deserialize, +)] +pub struct Option(core::option::Option); + +impl core::ops::Deref for Option { + type Target = core::option::Option; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} +impl core::ops::DerefMut for Option { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl From> for Option { + fn from(x: core::option::Option) -> Self { + Self(x) + } +} + +impl From> for core::option::Option { + fn from(x: Option) -> Self { + x.0 + } +} + +impl Miniconf for Option { + fn set_path<'a, P: Peekable>( + &mut self, + path_parts: &'a mut P, + value: &[u8], + ) -> Result { + if let Some(inner) = self.0.as_mut() { + inner.set_path(path_parts, value) + } else { + Err(Error::PathNotFound) + } + } + + fn get_path<'a, P: Peekable>( + &self, + path_parts: &'a mut P, + value: &mut [u8], + ) -> Result { + if let Some(inner) = self.0.as_ref() { + inner.get_path(path_parts, value) + } else { + Err(Error::PathNotFound) + } + } + + fn metadata() -> Metadata { + T::metadata() + } + + fn next_path( + state: &mut [usize], + path: &mut heapless::String, + ) -> Result { + T::next_path(state, path) + } +} + +impl Miniconf for core::option::Option { + fn set_path<'a, P: Peekable>( + &mut self, + path_parts: &mut P, + value: &[u8], + ) -> Result { + if path_parts.peek().is_some() { + return Err(Error::PathTooLong); + } + + if self.is_none() { + return Err(Error::PathAbsent); + } + + let (value, len) = serde_json_core::from_slice(value)?; + *self = Some(value); + Ok(len) + } + + fn get_path<'a, P: Peekable>( + &self, + path_parts: &mut P, + value: &mut [u8], + ) -> Result { + if path_parts.peek().is_some() { + return Err(Error::PathTooLong); + } + + let data = self.as_ref().ok_or(Error::PathAbsent)?; + Ok(serde_json_core::to_slice(data, value)?) + } + + fn metadata() -> Metadata { + Metadata { + count: 1, + ..Default::default() + } + } + + fn next_path( + state: &mut [usize], + path: &mut heapless::String, + ) -> Result { + if *state.first().ok_or(IterError::PathDepth)? == 0 { + state[0] += 1; + + // Remove trailing slash added by a deferring container (array or struct). + if path.ends_with('/') { + path.pop(); + } + Ok(true) + } else { + Ok(false) + } + } +} diff --git a/tests/arrays.rs b/tests/arrays.rs index 1b800e46..5fc7a1a3 100644 --- a/tests/arrays.rs +++ b/tests/arrays.rs @@ -1,4 +1,4 @@ -use miniconf::{Error, Miniconf}; +use miniconf::{Array, Error, Miniconf}; use serde::Deserialize; #[derive(Debug, Default, Miniconf, Deserialize)] @@ -9,6 +9,7 @@ struct AdditionalSettings { #[derive(Debug, Default, Miniconf, Deserialize)] struct Settings { data: u32, + #[miniconf(defer)] more: AdditionalSettings, } @@ -16,84 +17,79 @@ struct Settings { fn simple_array() { #[derive(Miniconf, Default)] struct S { + #[miniconf(defer)] a: [u8; 3], } let mut s = S::default(); // Updating a single field should succeed. - let field = "a/0".split('/').peekable(); - s.string_set(field, "99".as_bytes()).unwrap(); + s.set("a/0", "99".as_bytes()).unwrap(); assert_eq!(99, s.a[0]); // Updating entire array atomically is not supported. - let field = "a".split('/').peekable(); - assert!(s.string_set(field, "[1,2,3]".as_bytes()).is_err()); + assert!(s.set("a", "[1,2,3]".as_bytes()).is_err()); // Invalid index should generate an error. - let field = "a/100".split('/').peekable(); - assert!(s.string_set(field, "99".as_bytes()).is_err()); + assert!(s.set("a/100", "99".as_bytes()).is_err()); } #[test] fn nonexistent_field() { #[derive(Miniconf, Default)] struct S { + #[miniconf(defer)] a: [u8; 3], } let mut s = S::default(); - let field = "a/b/1".split('/').peekable(); - - assert!(s.string_set(field, "7".as_bytes()).is_err()); + assert!(s.set("a/1/b", "7".as_bytes()).is_err()); } #[test] fn simple_array_indexing() { #[derive(Miniconf, Default)] struct S { + #[miniconf(defer)] a: [u8; 3], } let mut s = S::default(); - let field = "a/1".split('/').peekable(); - - s.string_set(field, "7".as_bytes()).unwrap(); + s.set("a/1", "7".as_bytes()).unwrap(); assert_eq!([0, 7, 0], s.a); // Ensure that setting an out-of-bounds index generates an error. - let field = "a/3".split('/').peekable(); - assert_eq!( - s.string_set(field, "7".as_bytes()).unwrap_err(), + assert!(matches!( + s.set("a/3", "7".as_bytes()).unwrap_err(), Error::BadIndex - ); + )); // Test metadata - let metadata = s.get_metadata(); - assert_eq!(metadata.max_depth, 3); - assert_eq!(metadata.max_topic_size, "a/2".len()); + let metadata = S::metadata(); + assert_eq!(metadata.max_depth, 2); + assert_eq!(metadata.max_length, "a/2".len()); + assert_eq!(metadata.count, 3); } #[test] fn array_of_structs_indexing() { - #[derive(Miniconf, Default, Clone, Copy, Deserialize, Debug, PartialEq)] + #[derive(Miniconf, Default, Clone, Copy, Debug, PartialEq)] struct Inner { b: u8, } #[derive(Miniconf, Default, PartialEq, Debug)] struct S { - a: [Inner; 3], + #[miniconf(defer)] + a: miniconf::Array, } let mut s = S::default(); - let field = "a/1/b".split('/').peekable(); - - s.string_set(field, "7".as_bytes()).unwrap(); + s.set("a/1/b", "7".as_bytes()).unwrap(); let expected = { let mut e = S::default(); @@ -104,7 +100,89 @@ fn array_of_structs_indexing() { assert_eq!(expected, s); // Test metadata - let metadata = s.get_metadata(); - assert_eq!(metadata.max_depth, 4); - assert_eq!(metadata.max_topic_size, "a/2/b".len()); + let metadata = S::metadata(); + assert_eq!(metadata.max_depth, 3); + assert_eq!(metadata.max_length, "a/2/b".len()); + assert_eq!(metadata.count, 3); +} + +#[test] +fn array_of_arrays() { + #[derive(Miniconf, Default, PartialEq, Debug)] + struct S { + #[miniconf(defer)] + data: miniconf::Array<[u32; 2], 2>, + } + + let mut s = S::default(); + + s.set("data/0/0", "7".as_bytes()).unwrap(); + + let expected = { + let mut e = S::default(); + e.data[0][0] = 7; + e + }; + + assert_eq!(expected, s); +} + +#[test] +fn atomic_array() { + #[derive(Miniconf, Default, PartialEq, Debug)] + struct S { + data: [u32; 2], + } + + let mut s = S::default(); + + s.set("data", "[1, 2]".as_bytes()).unwrap(); + + let expected = { + let mut e = S::default(); + e.data[0] = 1; + e.data[1] = 2; + e + }; + + assert_eq!(expected, s); +} + +#[test] +fn short_array() { + #[derive(Miniconf, Default, PartialEq, Debug)] + struct S { + #[miniconf(defer)] + data: [u32; 1], + } + + // Test metadata + let meta = S::metadata(); + assert_eq!(meta.max_depth, 2); + assert_eq!(meta.max_length, "data/0".len()); + assert_eq!(meta.count, 1); +} + +#[test] +fn null_array() { + #[derive(Miniconf, Default, PartialEq, Debug)] + struct S { + #[miniconf(defer)] + data: [u32; 0], + } + assert!(S::iter_paths::<2, 6>().unwrap().next().is_none()); +} + +#[test] +fn null_miniconf_array() { + #[derive(Miniconf, Default, Debug, PartialEq, Copy, Clone)] + struct I { + a: i32, + } + #[derive(Miniconf, Default, PartialEq, Debug)] + struct S { + #[miniconf(defer)] + data: Array, + } + assert!(S::iter_paths::<3, 8>().unwrap().next().is_none()); } diff --git a/tests/enums.rs b/tests/enums.rs index 1e843e6a..64a40353 100644 --- a/tests/enums.rs +++ b/tests/enums.rs @@ -3,47 +3,44 @@ use serde::{Deserialize, Serialize}; #[test] fn simple_enum() { - #[derive(Miniconf, Debug, Deserialize, Serialize, PartialEq)] + #[derive(Debug, Deserialize, Serialize, PartialEq)] enum Variant { A, B, } - #[derive(Miniconf, Debug, Deserialize, Serialize)] + #[derive(Miniconf, Debug)] struct S { v: Variant, } let mut s = S { v: Variant::A }; - let field = "v".split('/').peekable(); - - s.string_set(field, "\"B\"".as_bytes()).unwrap(); + s.set("v", "\"B\"".as_bytes()).unwrap(); assert_eq!(s.v, Variant::B); // Test metadata - let metadata = s.get_metadata(); - assert_eq!(metadata.max_depth, 2); - assert_eq!(metadata.max_topic_size, "v".len()); + let metadata = S::metadata(); + assert_eq!(metadata.max_depth, 1); + assert_eq!(metadata.max_length, "v".len()); + assert_eq!(metadata.count, 1); } #[test] fn invalid_enum() { - #[derive(Miniconf, Debug, Serialize, Deserialize, PartialEq)] + #[derive(Debug, Serialize, Deserialize, PartialEq)] enum Variant { A, B, } - #[derive(Miniconf, Debug, Deserialize)] + #[derive(Miniconf, Debug)] struct S { v: Variant, } let mut s = S { v: Variant::A }; - let field = "v".split('/').peekable(); - - assert!(s.string_set(field, "\"C\"".as_bytes()).is_err()); + assert!(s.set("v", "\"C\"".as_bytes()).is_err()); } diff --git a/tests/generics.rs b/tests/generics.rs index 93af4cfb..669acdee 100644 --- a/tests/generics.rs +++ b/tests/generics.rs @@ -1,43 +1,42 @@ -use miniconf::{Miniconf, MiniconfAtomic}; +use miniconf::Miniconf; use serde::{Deserialize, Serialize}; #[test] fn generic_type() { #[derive(Miniconf, Default)] - struct Settings { + struct Settings { pub data: T, } let mut settings = Settings::::default(); - settings - .string_set("data".split('/').peekable(), b"3.0") - .unwrap(); + settings.set("data", b"3.0").unwrap(); assert_eq!(settings.data, 3.0); // Test metadata - let metadata = settings.get_metadata(); - assert_eq!(metadata.max_depth, 2); - assert_eq!(metadata.max_topic_size, "data".len()); + let metadata = Settings::::metadata(); + assert_eq!(metadata.max_depth, 1); + assert_eq!(metadata.max_length, "data".len()); + assert_eq!(metadata.count, 1); } #[test] fn generic_array() { #[derive(Miniconf, Default)] - struct Settings { + struct Settings { + #[miniconf(defer)] pub data: [T; 2], } let mut settings = Settings::::default(); - settings - .string_set("data/0".split('/').peekable(), b"3.0") - .unwrap(); + settings.set("data/0", b"3.0").unwrap(); assert_eq!(settings.data[0], 3.0); // Test metadata - let metadata = settings.get_metadata(); - assert_eq!(metadata.max_depth, 3); - assert_eq!(metadata.max_topic_size, "data/0".len()); + let metadata = Settings::::metadata(); + assert_eq!(metadata.max_depth, 2); + assert_eq!(metadata.max_length, "data/0".len()); + assert_eq!(metadata.count, 2); } #[test] @@ -47,22 +46,21 @@ fn generic_struct() { pub inner: T, } - #[derive(MiniconfAtomic, Deserialize, Serialize, Default)] + #[derive(Serialize, Deserialize, Default)] struct Inner { pub data: f32, } let mut settings = Settings::::default(); - settings - .string_set("inner".split('/').peekable(), b"{\"data\": 3.0}") - .unwrap(); + settings.set("inner", b"{\"data\": 3.0}").unwrap(); assert_eq!(settings.inner.data, 3.0); // Test metadata - let metadata = settings.get_metadata(); - assert_eq!(metadata.max_depth, 2); - assert_eq!(metadata.max_topic_size, "inner".len()); + let metadata = Settings::::metadata(); + assert_eq!(metadata.max_depth, 1); + assert_eq!(metadata.max_length, "inner".len()); + assert_eq!(metadata.count, 1); } #[test] @@ -72,23 +70,20 @@ fn generic_atomic() { pub atomic: Inner, } - #[derive(Deserialize, Serialize, MiniconfAtomic, Default)] + #[derive(Deserialize, Serialize, Default)] struct Inner { pub inner: [T; 5], } let mut settings = Settings::::default(); settings - .string_set( - "atomic".split('/').peekable(), - b"{\"inner\": [3.0, 0, 0, 0, 0]}", - ) + .set("atomic", b"{\"inner\": [3.0, 0, 0, 0, 0]}") .unwrap(); assert_eq!(settings.atomic.inner[0], 3.0); // Test metadata - let metadata = settings.get_metadata(); - assert_eq!(metadata.max_depth, 2); - assert_eq!(metadata.max_topic_size, "atomic".len()); + let metadata = Settings::::metadata(); + assert_eq!(metadata.max_depth, 1); + assert_eq!(metadata.max_length, "atomic".len()); } diff --git a/tests/integration_test.rs b/tests/integration_test.rs index a41dfa3f..eb6dce4a 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -1,23 +1,23 @@ use machine::*; use miniconf::{ - minimq::{QoS, Retain}, + minimq::{types::TopicFilter, Publication}, Miniconf, }; -use serde::Deserialize; use std_embedded_nal::Stack; use std_embedded_time::StandardClock; #[macro_use] extern crate log; -#[derive(Debug, Default, Miniconf, Deserialize)] +#[derive(Clone, Debug, Default, Miniconf)] struct AdditionalSettings { inner: u8, } -#[derive(Debug, Default, Miniconf, Deserialize)] +#[derive(Clone, Debug, Default, Miniconf)] struct Settings { data: u32, + #[miniconf(defer)] more: AdditionalSettings, } @@ -106,6 +106,7 @@ fn main() -> std::io::Result<()> { "device", localhost, StandardClock::default(), + Settings::default(), ) .unwrap(); @@ -125,19 +126,20 @@ fn main() -> std::io::Result<()> { let setting_update = interface.update().unwrap(); match state { TestState::Started(_) => { - if timer.is_complete() && mqtt.client.is_connected() { + if timer.is_complete() && mqtt.client().is_connected() { // Subscribe to the default device log topic. - mqtt.client.subscribe("device/log", &[]).unwrap(); + mqtt.client() + .subscribe(&[TopicFilter::new("device/log")], &[]) + .unwrap(); // Send a request to set a property. info!("Sending first settings value"); - mqtt.client + mqtt.client() .publish( - "device/settings/data", - "500".as_bytes(), - QoS::AtMostOnce, - Retain::NotRetained, - &[], + Publication::new(b"500") + .topic("device/settings/data") + .finish() + .unwrap(), ) .unwrap(); state = state.on_advance(Advance); @@ -149,13 +151,12 @@ fn main() -> std::io::Result<()> { if timer.is_complete() || setting_update { assert!(setting_update); info!("Sending inner settings value"); - mqtt.client + mqtt.client() .publish( - "device/settings/more/inner", - "100".as_bytes(), - QoS::AtMostOnce, - Retain::NotRetained, - &[], + Publication::new(b"100") + .topic("device/settings/more/inner") + .finish() + .unwrap(), ) .unwrap(); state = state.on_advance(Advance); diff --git a/tests/iter.rs b/tests/iter.rs index ee2a28e1..13bb919a 100644 --- a/tests/iter.rs +++ b/tests/iter.rs @@ -9,37 +9,33 @@ struct Inner { struct Settings { a: f32, b: i32, + #[miniconf(defer)] c: Inner, } #[test] fn insufficient_space() { - let settings = Settings::default(); - let meta = settings.get_metadata(); - assert_eq!(meta.max_depth, 3); - assert_eq!(meta.max_topic_size, "c/inner".len()); + let meta = Settings::metadata(); + assert_eq!(meta.max_depth, 2); + assert_eq!(meta.max_length, "c/inner".len()); + assert_eq!(meta.count, 3); // Ensure that we can't iterate if we make a state vector that is too small. - let mut small_state = [0; 2]; - assert!(settings.into_iter::<256>(&mut small_state).is_err()); + assert!(Settings::iter_paths::<1, 256>().is_err()); // Ensure that we can't iterate if the topic buffer is too small. - let mut state = [0; 10]; - assert!(settings.into_iter::<1>(&mut state).is_err()); + assert!(Settings::iter_paths::<10, 1>().is_err()); } #[test] fn test_iteration() { - let settings = Settings::default(); - let mut iterated = std::collections::HashMap::from([ ("a".to_string(), false), ("b".to_string(), false), ("c/inner".to_string(), false), ]); - let mut iter_state = [0; 32]; - for field in settings.into_iter::<256>(&mut iter_state).unwrap() { + for field in Settings::iter_paths::<32, 256>().unwrap() { assert!(iterated.contains_key(&field.as_str().to_string())); iterated.insert(field.as_str().to_string(), true); } @@ -47,3 +43,20 @@ fn test_iteration() { // Ensure that all fields were iterated. assert!(iterated.iter().map(|(_, value)| value).all(|&x| x)); } + +#[test] +fn test_array_iteration() { + #[derive(Miniconf, Default)] + struct Settings { + #[miniconf(defer)] + data: [bool; 5], + } + + let mut settings = Settings::default(); + + for field in Settings::iter_paths::<32, 256>().unwrap() { + settings.set(&field, b"true").unwrap(); + } + + assert!(settings.data.iter().all(|x| *x)); +} diff --git a/tests/option.rs b/tests/option.rs new file mode 100644 index 00000000..87c650c7 --- /dev/null +++ b/tests/option.rs @@ -0,0 +1,107 @@ +use miniconf::Miniconf; + +#[derive(PartialEq, Debug, Clone, Default, Miniconf)] +struct Inner { + data: u32, +} + +#[derive(Debug, Clone, Default, Miniconf)] +struct Settings { + #[miniconf(defer)] + value: miniconf::Option, +} + +#[test] +fn option_get_set_none() { + let mut settings = Settings::default(); + let mut data = [0; 100]; + + // Check that if the option is None, the value cannot be get or set. + settings.value.take(); + assert!(settings.get("value", &mut data).is_err()); + assert!(settings.set("value/data", b"5").is_err()); +} + +#[test] +fn option_get_set_some() { + let mut settings = Settings::default(); + let mut data = [0; 10]; + + // Check that if the option is Some, the value can be get or set. + settings.value.replace(Inner { data: 5 }); + + let len = settings.get("value/data", &mut data).unwrap(); + assert_eq!(&data[..len], b"5"); + + settings.set("value/data", b"7").unwrap(); + assert_eq!(settings.value.as_ref().unwrap().data, 7); +} + +#[test] +fn option_iterate_some_none() { + let mut settings = Settings::default(); + + // When the value is None, it will still be iterated over as a topic but may not exist at runtime. + settings.value.take(); + let mut iterator = Settings::iter_paths::<10, 128>().unwrap(); + assert_eq!(iterator.next().unwrap(), "value/data"); + assert!(iterator.next().is_none()); + + // When the value is Some, it should be iterated over. + settings.value.replace(Inner { data: 5 }); + let mut iterator = Settings::iter_paths::<10, 128>().unwrap(); + assert_eq!(iterator.next().unwrap(), "value/data"); + assert!(iterator.next().is_none()); +} + +#[test] +fn option_test_normal_option() { + #[derive(Copy, Clone, Default, Miniconf)] + struct S { + data: Option, + } + + let mut s = S::default(); + assert!(s.data.is_none()); + + let mut iterator = S::iter_paths::<10, 128>().unwrap(); + assert_eq!(iterator.next(), Some("data".into())); + assert!(iterator.next().is_none()); + + s.set("data", b"7").unwrap(); + assert_eq!(s.data, Some(7)); + + let mut iterator = S::iter_paths::<10, 128>().unwrap(); + assert_eq!(iterator.next(), Some("data".into())); + assert!(iterator.next().is_none()); + + s.set("data", b"null").unwrap(); + assert!(s.data.is_none()); +} + +#[test] +fn option_test_defer_option() { + #[derive(Copy, Clone, Default, Miniconf)] + struct S { + #[miniconf(defer)] + data: Option, + } + + let mut s = S::default(); + assert!(s.data.is_none()); + + let mut iterator = S::iter_paths::<10, 128>().unwrap(); + assert_eq!(iterator.next(), Some("data".into())); + assert!(iterator.next().is_none()); + + assert!(s.set("data", b"7").is_err()); + s.data = Some(0); + s.set("data", b"7").unwrap(); + assert_eq!(s.data, Some(7)); + + let mut iterator = S::iter_paths::<10, 128>().unwrap(); + assert_eq!(iterator.next(), Some("data".into())); + assert!(iterator.next().is_none()); + + assert!(s.set("data", b"null").is_err()); +} diff --git a/tests/republish.rs b/tests/republish.rs index c1b96b5b..6c76fed4 100644 --- a/tests/republish.rs +++ b/tests/republish.rs @@ -1,17 +1,19 @@ -use tokio; - -use miniconf::{minimq, Miniconf}; +use miniconf::{ + minimq::{self, types::TopicFilter}, + Miniconf, +}; use std_embedded_nal::Stack; use std_embedded_time::StandardClock; -#[derive(Debug, Default, Miniconf)] +#[derive(Clone, Debug, Default, Miniconf)] struct AdditionalSettings { inner: u8, } -#[derive(Debug, Default, Miniconf)] +#[derive(Clone, Debug, Default, Miniconf)] struct Settings { data: u32, + #[miniconf(defer)] more: AdditionalSettings, } @@ -26,15 +28,15 @@ async fn verify_settings() { .unwrap(); // Wait for the broker connection - while !mqtt.client.is_connected() { + while !mqtt.client().is_connected() { mqtt.poll(|_client, _topic, _message, _properties| {}) .unwrap(); tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; } // Subscribe to the settings topic. - mqtt.client - .subscribe("republish/device/settings/#", &[]) + mqtt.client() + .subscribe(&[TopicFilter::new("republish/device/settings/#")], &[]) .unwrap(); // Wait the other device to connect and publish settings. @@ -86,13 +88,15 @@ async fn main() { "republish/device", "127.0.0.1".parse().unwrap(), StandardClock::default(), + Settings::default(), ) .unwrap(); // Poll the client for 5 seconds. This should be enough time for the miniconf client to publish // all settings values. for _ in 0..500 { - interface.update().unwrap(); + // The interface should never indicate a settings update during the republish process. + assert!(!interface.update().unwrap()); tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; } diff --git a/tests/structs.rs b/tests/structs.rs index 35897a32..4ec4ece6 100644 --- a/tests/structs.rs +++ b/tests/structs.rs @@ -1,9 +1,9 @@ -use miniconf::{Miniconf, MiniconfAtomic}; +use miniconf::Miniconf; use serde::{Deserialize, Serialize}; #[test] fn atomic_struct() { - #[derive(MiniconfAtomic, Default, PartialEq, Debug, Serialize, Deserialize)] + #[derive(Serialize, Deserialize, Default, PartialEq, Debug)] struct Inner { a: u32, b: u32, @@ -18,14 +18,11 @@ fn atomic_struct() { let mut settings = Settings::default(); - let field = "c/a".split('/').peekable(); - // Inner settings structure is atomic, so cannot be set. - assert!(settings.string_set(field, b"4").is_err()); + assert!(settings.set("c/a", b"4").is_err()); // Inner settings can be updated atomically. - let field = "c".split('/').peekable(); - settings.string_set(field, b"{\"a\": 5, \"b\": 3}").unwrap(); + settings.set("c", b"{\"a\": 5, \"b\": 3}").unwrap(); let expected = { let mut expected = Settings::default(); @@ -37,9 +34,10 @@ fn atomic_struct() { assert_eq!(settings, expected); // Check that metadata is correct. - let metadata = settings.get_metadata(); - assert_eq!(metadata.max_depth, 2); - assert_eq!(metadata.max_topic_size, "c".len()); + let metadata = Settings::metadata(); + assert_eq!(metadata.max_depth, 1); + assert_eq!(metadata.max_length, "c".len()); + assert_eq!(metadata.count, 3); } #[test] @@ -53,14 +51,13 @@ fn recursive_struct() { struct Settings { a: f32, b: bool, + #[miniconf(defer)] c: Inner, } let mut settings = Settings::default(); - let field = "c/a".split('/').peekable(); - - settings.string_set(field, b"3").unwrap(); + settings.set("c/a", b"3").unwrap(); let expected = { let mut expected = Settings::default(); expected.c.a = 3; @@ -70,11 +67,35 @@ fn recursive_struct() { assert_eq!(settings, expected); // It is not allowed to set a non-terminal node. - let field = "c".split('/').peekable(); - assert!(settings.string_set(field, b"{\"a\": 5}").is_err()); + assert!(settings.set("c", b"{\"a\": 5}").is_err()); // Check that metadata is correct. - let metadata = settings.get_metadata(); - assert_eq!(metadata.max_depth, 3); - assert_eq!(metadata.max_topic_size, "c/a".len()); + let metadata = Settings::metadata(); + assert_eq!(metadata.max_depth, 2); + assert_eq!(metadata.max_length, "c/a".len()); + assert_eq!(metadata.count, 3); +} + +#[test] +fn struct_with_string() { + #[derive(Miniconf, Default)] + struct Settings { + string: heapless::String<10>, + } + + let mut s = Settings::default(); + + let mut buf = [0u8; 256]; + let len = s.get("string", &mut buf).unwrap(); + assert_eq!(&buf[..len], b"\"\""); + + s.set("string", br#""test""#).unwrap(); + assert_eq!(s.string, "test"); +} + +#[test] +fn empty_struct() { + #[derive(Miniconf, Default)] + struct Settings {} + assert!(Settings::iter_paths::<1, 0>().unwrap().next().is_none()); } diff --git a/tests/validation_failure.rs b/tests/validation_failure.rs new file mode 100644 index 00000000..8d7a7636 --- /dev/null +++ b/tests/validation_failure.rs @@ -0,0 +1,117 @@ +use miniconf::{minimq, Miniconf}; +use serde::Deserialize; +use std_embedded_nal::Stack; +use std_embedded_time::StandardClock; + +const RESPONSE_TOPIC: &str = "validation_failure/device/response"; + +#[derive(Clone, Debug, Default, Miniconf)] +struct Settings { + error: bool, +} + +#[derive(Deserialize)] +struct Response { + code: u8, + _message: heapless::String<256>, +} + +async fn client_task() { + // Construct a Minimq client to the broker for publishing requests. + let mut mqtt: minimq::Minimq<_, _, 256, 1> = miniconf::minimq::Minimq::new( + "127.0.0.1".parse().unwrap(), + "tester", + Stack::default(), + StandardClock::default(), + ) + .unwrap(); + + // Wait for the broker connection + while !mqtt.client().is_connected() { + mqtt.poll(|_client, _topic, _message, _properties| {}) + .unwrap(); + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + } + + let topic_filter = minimq::types::TopicFilter::new(RESPONSE_TOPIC); + mqtt.client().subscribe(&[topic_filter], &[]).unwrap(); + + // Wait the other device to connect. + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + + // Configure the error variable to trigger an internal validation failure. + let properties = [minimq::Property::ResponseTopic(minimq::types::Utf8String( + RESPONSE_TOPIC, + ))]; + + log::info!("Publishing error setting"); + mqtt.client() + .publish( + minimq::Publication::new(b"true") + .topic("validation_failure/device/settings/error") + .properties(&properties) + .finish() + .unwrap(), + ) + .unwrap(); + + // Wait until we get a response to the request. + loop { + if let Some(false) = mqtt + .poll(|_client, _topic, message, _properties| { + let data: Response = serde_json_core::from_slice(message).unwrap().0; + assert!(data.code != 0); + false + }) + .unwrap() + { + break; + } + + tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; + } +} + +#[tokio::test] +async fn main() { + env_logger::init(); + + // Spawn a task to send MQTT messages. + tokio::task::spawn(async move { client_task().await }); + + // Construct a settings configuration interface. + let mut interface: miniconf::MqttClient = miniconf::MqttClient::new( + Stack::default(), + "", + "validation_failure/device", + "127.0.0.1".parse().unwrap(), + StandardClock::default(), + Settings::default(), + ) + .unwrap(); + + // Update the client until the exit + let mut should_exit = false; + loop { + interface + .handled_update(|_path, _old_settings, new_settings| { + log::info!("Handling setting update"); + if new_settings.error { + should_exit = true; + return Err("Exiting now"); + } + + return Ok(()); + }) + .unwrap(); + + if should_exit { + break; + } + + tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; + } + + // Check that the error setting did not stick. + assert!(!interface.settings().error); +}