Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,21 @@
# 0.4.0 (Nov 26th, 2025)

## Breaking Changes

* Published field streams now yield the current value on first poll, then subsequent changes.
* Published field streams' item type is now the raw field type (e.g., `State`) instead of
`*Changed` struct with `previous` and `new` fields.
* The `pub_setter` sub-attribute on `publish` has been removed. Use the new independent `setter`
attribute instead (e.g., `#[controller(publish, setter)]`).

## New Features

* New `getter` attribute for fields: generates a client-side getter method. Supports custom naming
via `#[controller(getter = "custom_name")]`.
* New `setter` attribute for fields: generates a client-side setter method independent of `publish`.
Supports custom naming via `#[controller(setter = "custom_name")]`. Can be combined with `publish`
to also broadcast changes.

# 0.3.0 (Nov 25th, 2025)

* Macro now operates on a module. This allows the macro to have a visibility on both the struct and
Expand Down
50 changes: 36 additions & 14 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ This is a procedural macro crate that provides the `#[controller]` attribute mac

* A controller struct that manages peripheral state.
* Client API for sending commands to the controller.
* Signal mechanism for broadcasting events.
* Pub/sub system for state change notifications.
* Signal mechanism for broadcasting events (PubSubChannel).
* Watch-based subscriptions for state change notifications (yields current value first).

The macro is applied to a module containing both the controller struct definition and its impl block, allowing coordinated code generation of the controller infrastructure, client API, and communication channels.

Expand Down Expand Up @@ -52,18 +52,31 @@ The `expand_module()` function:
* Combines the generated code back into the module structure along with any other items.

Channel capacities and subscriber limits are also defined here:
* `ALL_CHANNEL_CAPACITY`: 8
* `SIGNAL_CHANNEL_CAPACITY`: 8
* `BROADCAST_MAX_PUBLISHERS`: 1
* `BROADCAST_MAX_SUBSCRIBERS`: 16
* `ALL_CHANNEL_CAPACITY`: 8 (method/getter/setter request channels)
* `SIGNAL_CHANNEL_CAPACITY`: 8 (signal PubSubChannel queue size)
* `BROADCAST_MAX_PUBLISHERS`: 1 (signals only)
* `BROADCAST_MAX_SUBSCRIBERS`: 16 (Watch for published fields, PubSubChannel for signals)

### Struct Processing (`src/controller/item_struct.rs`)
Processes the controller struct definition. For fields marked with `#[controller(publish)]`:
* Adds publisher fields to the struct.
* Generates setters (`set_<field>`) that broadcast changes.
* Creates `<StructName><FieldName>` stream type and `<StructName><FieldName>Changed` event struct.
Processes the controller struct definition. Supports three field attributes:

The generated `new()` method initializes both user fields and generated publisher fields.
**`#[controller(publish)]`** - Enables state change subscriptions:
* Uses `embassy_sync::watch::Watch` channel (stores latest value).
* Generates internal setter (`set_<field>`) that broadcasts changes.
* Creates `<StructName><FieldName>` subscriber stream type.
* Stream yields current value on first poll, then subsequent changes.

**`#[controller(getter)]` or `#[controller(getter = "name")]`**:
* Generates a client-side getter method to read the field value.
* Default name is the field name; custom name can be specified.

**`#[controller(setter)]` or `#[controller(setter = "name")]`**:
* Generates a client-side setter method to update the field value.
* Default name is `set_<field>`; custom name can be specified.
* Can be combined with `publish` to also broadcast changes.

The generated `new()` method initializes both user fields and generated sender fields, and sends
initial values to Watch channels so subscribers get them immediately.

### Impl Processing (`src/controller/item_impl.rs`)
Processes the controller impl block. Distinguishes between:
Expand All @@ -75,11 +88,18 @@ Processes the controller impl block. Distinguishes between:

**Signal methods** (marked with `#[controller(signal)]`):
* Methods have no body in the user's impl block.
* Uses `embassy_sync::pubsub::PubSubChannel` for broadcast.
* Generates method implementation that broadcasts to subscribers.
* Creates `<StructName><MethodName>` stream type and `<StructName><MethodName>Args` struct.
* Signal methods are NOT exposed in the client API (controller emits them directly).

The generated `run()` method contains a `select_biased!` loop that receives method calls from clients and dispatches them to the user's implementations.
**Getter/setter methods** (from struct field attributes):
* Receives getter/setter field info from struct processing.
* Generates client-side getter methods that request current field value.
* Generates client-side setter methods that update field value (and broadcast if published).

The generated `run()` method contains a `select_biased!` loop that receives method calls from
clients and dispatches them to the user's implementations.

### Utilities (`src/util.rs`)
Case conversion functions (`pascal_to_snake_case`, `snake_to_pascal_case`) used for generating type and method names.
Expand All @@ -97,5 +117,7 @@ Dev dependencies include `embassy-executor` and `embassy-time` for testing.
* Singleton operation: multiple controller instances interfere with each other.
* Methods must be async and cannot use reference parameters/return types.
* Maximum 16 subscribers per state/signal stream.
* Published fields must implement `Clone` and `Debug`.
* Streams must be continuously polled or notifications are missed.
* Published fields must implement `Clone`.
* Published field streams yield current value on first poll; intermediate values may be missed if
not polled between changes.
* Signal streams must be continuously polled or notifications are missed.
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[package]
name = "firmware-controller"
description = "Controller to decouple interactions between components in a no_std environment."
version = "0.3.0"
version = "0.4.0"
edition = "2021"
authors = [
"Zeeshan Ali Khan <zeenix@gmail.com>",
Expand Down
42 changes: 25 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,28 +109,29 @@ async fn client() {
use embassy_time::{Timer, Duration};

let mut client = ControllerClient::new();
let state_changed = client.receive_state_changed().unwrap().map(Either::Left);
let mut state_stream = client.receive_state_changed().unwrap();
let error_stream = client.receive_power_error().unwrap().map(Either::Right);

// First poll returns the current (initial) state.
let initial_state = state_stream.next().await.unwrap();
assert_eq!(initial_state, State::Disabled);

// Now combine streams for event handling.
let state_changed = state_stream.map(Either::Left);
let mut stream = select(state_changed, error_stream);

client.enable_power().await.unwrap();
while let Some(event) = stream.next().await {
match event {
Either::Left(ControllerStateChanged {
new: State::Enabled,
..
}) => {
Either::Left(State::Enabled) => {
// This is fine in this very simple example where we've only one client in a single
// task. In a real-world application, you should ensure that the stream is polled
// continuously. Otherwise, you might miss notifications.
Timer::after(Duration::from_secs(1)).await;

client.disable_power().await.unwrap();
}
Either::Left(ControllerStateChanged {
new: State::Disabled,
..
}) => {
Either::Left(State::Disabled) => {
Timer::after(Duration::from_secs(1)).await;

client.enable_power().await.unwrap();
Expand Down Expand Up @@ -169,11 +170,16 @@ methods:
controller and return the results.
* For each `published` field:
* `receive_<field-name>_changed()` method (e.g., `receive_state_changed()`) that returns a
stream of state changes. The stream yields `<struct-name><field-name-in-pascal-case>Changed`
structs (e.g., `ControllerStateChanged`) containing `previous` and `new` fields.
* If the field is marked with `#[controller(publish(pub_setter))]`, a public
`set_<field-name>()` method (e.g., `set_state()`) is also generated on the client, allowing
external code to update the field value through the client API.
stream of state values. The first value yielded is the current state at subscription time,
and subsequent values are emitted when the field changes. The stream yields values of the
field type directly (e.g., `State`).
* For each field with a `getter` attribute (e.g., `#[controller(getter)]` or
`#[controller(getter = "custom_name")]`), a getter method is generated on the client. The default
name is the field name; a custom name can be specified.
* For each field with a `setter` attribute (e.g., `#[controller(setter)]` or
`#[controller(setter = "custom_name")]`), a public setter method is generated on the client,
allowing external code to update the field value through the client API. The default setter
name is `set_<field-name>()`. This can be combined with `publish` to also broadcast changes.
* For each `signal` method:
* `receive_<method-name>()` method (e.g., `receive_power_error()`) that returns a stream of
signal events. The stream yields `<struct-name><method-name-in-pascal-case>Args` structs
Expand All @@ -196,6 +202,8 @@ The `controller` macro assumes that you have the following dependencies in your
* Methods must be async.
* The maximum number of subscribers state change and signal streams is 16. We plan to provide an
attribute to make this configurable in the future.
* The type of all published fields must implement `Clone` and `Debug`.
* The signal and published fields' streams must be continuely polled. Otherwise notifications will
be missed.
* The type of all published fields must implement `Clone`.
* Published field streams yield the current value on first poll, then subsequent changes. Only the
latest value is stored; intermediate values may be missed if the stream is not polled between
changes.
* Signal streams must be continuously polled. Otherwise notifications will be missed.
Loading