Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RFC: Introducing a common interface for counters. #762

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
179 changes: 179 additions & 0 deletions text/0000-common-counter-interface.md
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I appreciate you writing this up! In particular, I would very much like to see the following items, which I don't see in the current draft:

  • discussion of potential wraparound conditions, particularly if u64 is not a mandatory representation of the counter value
  • discussion of how you expect platforms with limited range timers (e.g. 16-bit only timers) to participate for u32 and u64 based counter values
  • If necessary (particularly for increasing smaller timers to larger ranges), any external resources, such as interrupts, that may be necessary for implementation
  • How you expect sharing of these clock/counter resources to work, for example if multiple drivers need to use them to provide timing capabilities
  • Any additional API surface, such as how "wait for timer to complete" would look

I'd also really really like to see:

  • The concrete traits you are proposing, for example in a a published crate (on GitHub or crates-io)
  • At least one example of implementing these traits, for your hardware platform or HAL of choice, likely in a fork of an existing HAL
  • At least one example of a downstream driver crate, that uses this trait
  • At least one example of an application, that links the HAL-provided trait impl and the trait-consuming driver, e.g. an "end to end example"

These examples are vitally necessary for evaluation - both for you to determine if the proposed interface(s) are reasonable in practice, as well as for others to comprehend the full extent of what you are proposing.

I'm not on the HAL team, but I imagine all the items I have listed here (and in comments) will be strong points of discussion, so for your RFC to be accepted it should likely address or dismiss these concerns.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your first group of points illustrate the need to ensure that everything that is implementation detail be left to the implementor. If a hal author of a specific platform or usecase is in any way hampered in their ability for their implementation to be encapsulated appropriately, then that is a failure of the trait definitions. For example, platforms with 16 bit counters could track wrappings themselves, or they could just say "out counter wraps" or whatever they choose to do.

The proposal in this RFC is to standardise an interface, with the only restriction on implementation detail being at the type constraint level.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, however, it is necessary to at least demonstrate that your proposed interface is practically usable before agreeing on that - and the best way we have today is by providing at least one (ideally a few diverse) implementations.

We've had many designs in embedded-hal that SEEMED like good ideas, or were good in isolation, but did not interact well beyond basic "hello world" interactions. This is particularly true in SPI and I2C, where it became clear between 0.2 and 1.0 that it was necessary to address sharing of a bus, as many practical systems had more than one device on the bus.

Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
- Feature Name: Common Interface for reading Clocks and other counters
- Start Date: 2024-05-22
- RFC PR: (leave this empty)
- Rust Issue: (leave this empty)

# Summary
[summary]: #summary

The embedded-hal traits `InputPin`, `OutputPin`, `I2c`, etc. key elements on the journey towards rust becoming
a first-class-citizen in the embedded devolopment space. Of notable absence is such a trait for reading an incremental counter,
and coupling that read to a unit being counted. This RFC calls for the development of such a trait.

**Example 1: Clock**
The trait would be implemented such that it reads from a clocks tick-count register, and assosciated type(s)/const(s) would allow
this to be expressed as a measure of time. This implementation can be used wherever one needs a clock to get a measure of time, and
`embedded_hal::counters::Clock` trait is in scope.

**Example 2: Usage Tracking**

A embedded dev wishes to track total movement of a machine. The couple an AB encoder to a ++ only pulse-count peripheral.
They implement counter such that a `read` will read from the pulse-count peripheral. They define their own trait extension internally
named `LifetimeMoveCount: embedded_hal::count::Counter`, and constrain it such that Counter must specify a distance for each count.
Their `LifetimeMoveCount` defers to `Counter` to get a read of movement, but its own impls defines long-term caching of data (e.g.
non volatile storage, or send reports to a server, etc).

# Motivation
[motivation]: #motivation

`esp-hal` has one implementation of representing time. `embassy-time` is another. If you look through any platform specific hal,
you are sure to find two things:

- Comprehensive implementation of the embedded-hal common interface.
- Their own implementation of getting time.
- Where applicable, their own interface for reading and interpreting counters, such as pulse counters.

One can put together a library that is platform agnostic by means of using common interfaces. Much like the way
`where LedP: SetDutyCycle` clause in a method makes it usable across the board, such as with this e.g....:

```rust
fn set_brightness<LedP: SetDutyCycle>(pin: LedP, brightness_pcnt: u8) -> Encoder {
pin.set_duty_cycle_percent(brightness_pcnt)
}
```

...embedded developers ought to have access to a common counter interface

```rust
// rotate brightness between 50 and 100% every 15 seconds using a clock-specific extension of a counting interface.
fn time_rotated_brightness<LedP: SetDutyCycle, ClkSrc: ReadClock>(led_pin: LedP, clk: ClkSrc) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this interface work well with shared access? e.g. &ClkSrc?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see why that should be difficult, and if it was, why it should not be overcome.

One thing to consider, is the question of how this should differ from, say, an InputPin implementor. conceptually, it's no different. instead of reading an inherently boolean state, it's a count, with the addition of a specification of what's being counted.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This typically becomes challenging if you need to have more complex inner state - e.g. expanding a 16-bit timer to larger ranges. Something to keep in mind if you need to change other parts of the design.

let age: MiliSecs<_> = clk.read_count().time_scaled();
let age: u16 = age.into();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noting that this probably need to be fallible, for example if we are more than 66 seconds into the runtime.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreed. This specific code is useless outside of the scope of illustrating a point. Key ideas is to communicate a potential idiom.

let modulod = age.integer() % 15_000;
let numer = // math to map and scale the modulod to a triange pattern between 7_500 and 15_000
led_pin.set_duty_cycle_fraction(numer, 15_000)
}
```

This example is quite contrived, but for every situation in which someone needs to read an oscilator count, there is no core
standard for this, the way there is for things like gpio pins, i2c, etc.

# Detailed design
[design]: #detailed-design

The core influence comes from two sources:

- `embedded-hal`: Just as this crate makes no impositions on specific implementation, but rather, encapsulates interfaces that
represent the most fundamental of behavior.
- `embedded-time`: An independant endeavour that does a great job of already encapsulating this core idea.

This is the code defining the `embedded_time::Clock` trait. I have changed the comments for the context of this RFC:
```rust
// I would rename this to `TickCount`. A consistent oscilator is not necisarily a clock. It could be a PWM at 50% duty cycle,
// or an ab encoder tracking total distance moved, etc. The only assumption is that it is an incremental counter, and that
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this address platforms where only down-counting timers exist? Are implementors expected to handle this (e.g. u32::MAX - raw_value?)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At this point no. A good design would be mindful of behavior where countdown is a posibility, but for now, I'm thinking in torms of strictly incrementing counts.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gotcha - something to keep in mind. I have go figure out what platform it is, but IIRC a fairly typical platform only has down-counting timers available.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the assosciated type I'm going with at this point (might drop the Copy):

    /// Typically u32 or u64 (or held within a type-wrapper) that corresponds with the
    /// data type held within the peripheral register being read.
    type RawData: Copy;

I've been ruminating on this question a bit, and I can't find any technical, development, portability, etc. reason not to have this on the forefront. Right now, the only thing stopping me, is finding a chance to do a bit of a case-study of making a hal/driver/etc portable through this trait, and needs this behavior yo describe.

I anticipate it being (relatively) trivial. My guess is a hal impl would implement try_read_raw, and do the mapping you describe. They would set the raw-data type to be u32, do the u32::max - $REGISTER_READ (or similar), then provide their own mappings to a data type that encapsulates the thing they are measuring.

// each increment has a specific meaning (be it a meanure of time, distance, etc.).
pub trait Clock: Sized {

// This `T` assosciated type is constrained to u32 or u64. I agree with constraining to an unsigned integer, but I am of the
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If u32 is used, is there an opinion of what to do in the case of wraparounds? For example, 1MHz based timers are somewhat common and desired for timing, but this will wrap around a u32 in approximately 71 minutes. Even a 32.768kHz time will wrap a u32 in 1.5 days.

A u64 can probably practically be considered infinite, even at 1GHz, it would take ~584 years to wrap a u64.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would be up to the implementor.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"that would be up to the implementor" - but this affects downstream users (e.g. drivers consuming the trait). Can drivers rely that the number always goes up? If so, how is this behavior ensured if the implementor provides a 1MHz 32 bit timer that works for the first 71 minutes, but not afterwards?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nailing down this kind of three-way contract between the HAL implementor, the trait interface, and the trait-using driver is pretty much the whole challenge of stabilizing these APIs, there's only so much that can be left to the implementor, while still providing a useful contract to the consuming driver.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added a draft of a concept where you can extend a time-counter:

/// There are many scenarios where a time-based tick-counter needs to handle the case
/// where the counter overflows. This trait is intended to provide a HAL with the means
/// of providing a `TimeCount` implementor with specialised interupt-based overflow
/// tracking. 
pub trait TimeWrap: TimeCount {
    /// A HAL implementation might register the provided method to handle an overflow
    /// interupt and increment a field that tracks overflow.
    ///
    /// META: I honestly don't know the best way to do this. This might prove to be
    ///       a key technical challenge
    fn register_wrap_interupt(self, ???);
}

Is it the good way? dunno. Keen to hear thoughts.

// opinion that it can be _any_ unsigned integer; It shall be the decision of the implementor to choose the type.
//
// In addition, maybe integers-only as too limiting, as exotic types like `Wrapper(u32)` should be available. The guiding
// principal here is "You are reading a thing that counts the number of occurances of an event." Unsigned integers make
// the most sense on what to reach for, but that should be a decision for the implementor.
type T: TimeInt + Hash;

// The original code runs its own fraction implementation. Strictly with respect to time, I feel using `fugit` is more
// prudent.
//
// More broadly, I feel this should be an encapsulation of an interval. An interval could be a measure of time, a distance,
// angle, and so on, depending on what is being counted.
//
// I feel this should remain a compile-time defined statement.
const SCALING_FACTOR: Fraction;

// With respect to assosciated types for errors, the question that defines the design choices made should be "What is best
// for creating a standardised interface?"

// I would rename this to `try_read`. I would also like to raise the idea of mirroring the standard `Read` pattern, where
// it's not a return value that's used, but rather a passid in `&mut`. At its core, this interface should encapsulate
// the reading of a count that increments, tightly coupled to an encapsulation of what is being counted. This implies:
// - it can read time: An implementation of this trait would define a duration-per-count
// - it can read absolute movement: each increment would correspond to a distance.
// - it is a _reader_: but is it different enough to `Read` implementors that it merits different patterns?
// - it reads _generic_ units: the notion of `now` and `time` are implementation dependant
fn try_now(&self) -> Result<Instant<Self>, Error>;

// Not exactly sure about how to read into this, or what should be done. Will update this part of RFC in response to
// questions, feedback, and any further insight I gather. My thinking is anything that respembles construction or
// initialisation is out, much the same as any other trait in embedded-hal.
fn new_timer<Dur: Duration>(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this RFC propose a mechanism for multiplexing multiple timers, e.g. if new_timer is called multiple times? Is this Timer interface specified anywhere?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll have to think about this, but with respect to constructors, I anticipate not including it, much the same as with all the other traits in embedded-hal

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that's reasonable!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you mean "new_timer returns a new instance, but sometimes each instance just uses the same timer under the hood. What approach to allowing this is being proposed?", I see this as addressing the challenge of "shared state, not necisarily immutable", and using a multitude of multiplexed objects as one particular strategy.

I'm of the mind that the primary domain of portability sits within the the scope of how a thing is used. There could be a case for a "soft-standard" of construction, but the driving mandate should always be "portability in its use".

Some thoughts I've been reminating on:

  • Object exclusivity contradicts the premise of portability of count-readers. Time-based, or otherwise.
  • Shared mutable state is to be avoided, especially at the interface level.
  • Not being able to multiplex, or use other means to share a counter that's potentially mutable, is a deal-breaker.
  • Interior mutability patterns/idioms/lang-features/etc. at impl level should be able to resolve this contradiction nicely. Their use must not be limited in any way.
  • In particular, w.r.t. embedded, interior mutability must be available in the context of juggling interrupts, soft/hard-real-time use-cases, etc.

My gut is to use patterns of shared immutable state at the interface level, and ensure no restrictions are imposed to allow for sharing something that is imutable under the hood

To whit:

  • &self in the interface
  • Investigate the various multiplexing approaches used, and ensure that e-hal Just Works to make their solutions usable in a portable fashion.
  • Validate this in the context of embedded: interupts, real-time, etc.

If a particular implementation chooses to use multiple, unshared, mutable objects that multiplex a single thing under the hood, I don't see how that would be incompatible with the pattern I just proposed at first glance, but I expect to be proven wrong on that.

&self,
duration: Dur,
) -> Timer<param::OneShot, param::Armed, Self, Dur>
where
Dur: FixedPoint,
{
Timer::<param::None, param::None, Self, Dur>::new(&self, duration)
}
}
```

Up to this point, I've taken the concept of `Clock` and generalised it to `Counter`. I beleive that the `Clock` trait fits
in by being a trait extension of a `Counter` trait. Such an extension would further constrain the type that is being measured
with an aditional requirement that it is a representation of time (e.g. MilliSecs<...>), and perhaps add methods that are
clock-specific.

```rust
pub trait Clock: Counter
where <Self as Counter>::Output: Into<Seconds<...>>
{
fn read_time(&self) -> Seconds<...> {
let ticks = <Self as Counter>::read_ticks(self);
Seconds::from(ticks)
}
}
```

# How We Teach This
[how-we-teach-this]: #how-we-teach-this

Personally, I intend to make PRs into embedded-hal reverse dependencies, and update their encapsulation of time-tracking
to include implementations that are officially supported by the embedded working group. I also see the need to add examples
of both implementation, and use (such as the time-rotating e.g. I hypothesised above).

# Drawbacks
[drawbacks]: #drawbacks

I'm mostly basing my thoughts on the models that I see throughout the embedded-hal crate. The drawbacks assosciated with the
various modules that define a common interface for things like gpio pins, I2c, etc would ideally apply in the same manner here.

# Alternatives
[alternatives]: #alternatives

embassy-time has an encapsulation of time, however that lives in an async-first context.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a note: embassy-time is fully usable in non-async contexts. There is no requirement to use async at all. There are certainly other drawbacks you could mention (requirements to use u64, separate externally defined driver methods, mandated timer wheel/queue implementation, etc.), but these are fully separate from "async or not".

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I should add that embassy time is not intended to be a universally applicable encapsulation of a counter, or something along those lines.

many MCU hals have their own way of encapsulating time, and other incremental counts.
fugit is an embedded-first encapsulation of mapping time-oscilator counts to a measurement of time in SI units.


# Unresolved questions
[unresolved]: #unresolved-questions

How to move forward? I propose an `embedded-count` added to the rust-embedded repository, initially just a placeholder,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned, it is generally required to first get a working system, usually with at least 2-3 platform implementations, EXTERNALLY first, so that they can be evaluated in working operation, prior to merging anything or creating new repos in the rust-embedded space.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Understood, but I'm concerned about the chicken-egg paradox.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, however the HAL team is just as concerned about having/owning a crate that it isn't able to consider the design of.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see that there is much context I do not account for in my original intent. I'll educate myself on the context/standards/expectations and update my declared thoughts on moving forward.

I'll fork it, and begin initial work. At an appropriate time, it will be upstreamed, and v0.0.2 will be released. If all goes
well, its form will be representative of this RFC.

Are we comfortable with using `typenum` instead of const generics. This will remove the limitations of const generics
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the underlying counter ticks are truly decoupled from the concept of time, it would be good to discuss how this would work in practice. Many platforms don't have hardware floating point support, and still many platforms (Cortex-M0, M0+, likely others) don't necessarily have hardware division support.

Will it be reasonable for these platforms to perform the tick->time conversions?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would be up to each platform implementation. Fugit does everything using integers and in cost time with minor exceptions, and I wouldnt be surprised if platforms gravitated towards fugit primitives when counting for time.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not saying this is a deal breaker - but something to actively address and engage with. If the base timer is 1MHz, it'll require integer division to render milliseconds or seconds.

It would be good to discuss, even if the answer is "fugit does it like this, it is this cheap, reasonably priced for the features provided".

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One way of interpreting this comment, is that it's a proposal to include a canonical expression of time when writing portable embedded. Either as part of this RFC, or in a seperate, but closely related one.

Here are my thoughts so far, from most, to least portable:

  • fugit: u32 or u64. extreme emphasis on zero runtime costs through the use of consts
  • embassy time: u64 only
  • core library is a u64 and u32, and doesn't use the concept of "ticks"

For me, the easiest answer to what is most portable is "whatever type you choose", but that's not much use, I think this is a void that needs filling.

My take is that at least the following properties are needed in order to be considered maximumly embedded-portable:

  • Is based on raw-data, such as tick-count (fugit, embassy-time)
  • Raw data is of any type. If it's not an integer type, it should be a thin-wrapper around an integer type (Foo(T), with T being any integer)
  • A compile-time encapsulation of scale. For time, this would be the tick-frequency. (fugit, embassy_time)
  • Zero-cost-abstraction aliasing between common representations, such as millis, micros, days, etc. (fugit, embassy-time)
  • A set of type-aliases that explicitly represent different time measures without cost, such as MilliSeconds<u32> (fugit)
  • implementation of common conversions meeting zero-cost-abstraction, such as Into<embassy_time::Duration/Instant>, Into<core::time::Duration>, should be trivially easy. A code-base that uses portable e-hal time, then calls into embedded-time, the part of the code that does the type-conversion should be optimised away in every-respect before the executable binary is generated.

I feel the closest is fugit, but for me to feel fugit as ready to integrate into e-hal, I percieve these as some of the blocking issues.

  • u32 and u64 only. that covers the vast majority of platforms, perhaps, but u8, 16, 128 exist as well, and for domains other than time, such as pulse-counters, you might want signed values. Multiple values are also a possible scenario (a tick-counter and a rollover counter, for example).
  • It uses const-generics for encapsulating scale (e.g. a 80hz u32 tick-count would be Instant<u32, 1u32, 80u32>), which means integrating with other interfaces is hit-or-miss (they might only be able to use usizes, or if they use u32, and you want to bridge the two, you're stuck with u32 types). It makes no sense to me that code must choose a machine-representation of a number to describe the frequency of an oscilator.
  • It's waiting on some unstable const-generic features to add useful features (e.g. to do changing between bases).
  • It's limited to time-only: If you want to count, for example, milliradians with a magnetic AB rotary encoder, that could work perfectly fine, but the naming would be Bizare.

I would be 100% behind setting up some sort of e-hal encapsulation of measuring time, and other things that counters... count, but there's some work involved to get there, imho.

entirely, including the need for nightly features, at the risk of interface ergonomics, and compromising user familiarity.
I wish to explore the use of this crate, though I feel it's a core requirement that the change in UX to be trivial. I.e. other
than theneed to use `typenum::U5` where `5u32/64/size/8` would be used to set a generic const.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be good to fully demonstrate what the typenum-based interface (or any alternatives) would look like for users. See my top level notes wrt providing examples for discussion.


How should we approach assosciated error types?

What is the wish-list of industry, such as the various teams writing the hal crates for specific MCUs/platforms?

What constraits make sense? I feel that `Ord` ought to be required for the raw data being read. In my mind, a type that can
be used to count something, would necissarily have an ordering between any two possible values. I also think some thought should
be put into providing the maths where compatable. in pseudo-rust: `impl<A: Counter, B: Counter> Add<A::Output> for for B::Output`
with where-clauses that constrain that the output types can do the Add.