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

SPI: split into device/bus, allows sharing buses with automatic CS #351

Merged
merged 3 commits into from
Feb 23, 2022

Conversation

Dirbaio
Copy link
Member

@Dirbaio Dirbaio commented Jan 20, 2022

Because why not?

This separates the concepts of "SPI bus" and "SPI device". I got the idea of having traits for SPI devices from #299. It has an excellent breakdown of the issues of the status quo, so go read that first.

The difference from #299 is that proposes "repurposing" the existing traits to represent devices, while this PR proposes specifying the existing traits represent buses, and introduces a new trait for representing devices on top of that.

  • HALs impl the bus traits, just like now. No extra boilerplate.
  • Crates like shared-bus add the "bus mutexing" logic, to impl SpiDevice on top of SpiBus (+ OutputPin for CS). There's a wide variety of possible impls, all can satisfy the SpiDevice requirements:
    • Exclusive single-device, that just owns the entire bus
    • Single-thread, on top of RefCell
    • Multithreaded std, using std Mutex
    • Multithreaded embedded, using some AtomicBool taken flag.
    • Async embedded, using an async mutex (this'd require an async version of SpiDevice).
  • Drivers take T: SpiDevice (or SpiDeviceRead, SpiDeviceWrite), then they start a .transaction(), then use it to read/write/transfer data. The transaction ends automatically when returning. Example:
fn read_register(&mut self, addr: u8) -> Result<u8, Error> {
    let mut buf = [0; 1];
    self.spi.transaction(|bus| {
        bus.write(&[addr])?;
        bus.read(&mut buf)?;
    })?;
    Ok(buf[0])
}

No Transactional needed

Previous proposals boiled down to: for each method call (read/write/transfer), it automatically assets CS before, deasserts it after. This means two writes are really two transactions, which might not be what you want. To counter this, Transactional was added, so that you can do multiple operations in a single CS assert/deassert.

With this proposal, you have an explicit "Transaction" object, so you can easily do multiple reads/writes on it in a single transaction. This means Transactional is no longer needed.

The result is more flexible than Transactional, even. Take an imaginary device that responds to "read" operations with a status code, and the data only arrives on success. With this proposal you can easily read data then take conditional action. With Transactional there's no way, best you can do is read the data unconditionally, then discard it on error.

It allows for smaller memory usage as well. With Transactional you had to allocate buffer space for all operations upfront, with this you can do them as you go.

fn read_data(&mut self, addr: u8, dest: &mut [u8]) -> Result<(), Error> {
    self.spi.transaction(|bus| {
        bus.write(&[addr])?;

        // Read status code, 0x00 indicates success
        let mut buf = [0; 1];
        bus.read(&mut buf)?;
        if buf[0] != 0x00 {
            return Err(Error::ErrorCode(buf[0]));
        }

        // If the operation is successful, actually read the data, still in the same transaction
        bus.read(dest)
    })
}

@rust-highfive
Copy link

r? @eldruin

(rust-highfive has picked a reviewer for you, use r? to override)

@ryankurte
Copy link
Contributor

ryankurte commented Jan 20, 2022

hey another interesting option!

Because why not?

🤣

Drivers take T: SpiDevice (or SpiReadonlyDevice, SpiWriteonlyDevice), then they start a .transaction(), then use it to read/write/transfer data. The transaction ends automatically when dropped.

i guess you'd also have a fn done(mut self) to make it obvious how to explicitly close the transaction, or you'd have to document .drop().

With this proposal, you have an explicit "Transaction" object, so you can easily do multiple reads/writes on it in a single transaction. This means Transactional is no longer needed.

i believe this is achieved by both ManagedCS proposals, though it is still useful to have a mechanism for representing an arbitrary sequence of operations.

An alternative would be using a closure like in #350, but I think the "end transaction on drop" proposed here is better:

with this approach the CS is effectively scoped to the parent function scope rather than the transaction and requires explicit overriding with manual drop to maintain ordering, which would seem to make the API easier to misuse / opens opportunities for bugs like not closing and re-opening the transaction or not closing the transaction prior to moving on.

/// Code to do some SPI stuff then wait for 100ms
/// note that the missing `drop` means the delay will occur within the CS assertion rather than between CS assertions
/// which is not super clear from the code
fn something(&mut self) {
  let t = spi.transaction();
  // do some SPI stuff

  // Delay for 100ms to let the device get ready for the next operation
  delay_ms(100);
}

i prefer the closure because it make it obvious when the end of the transaction is, rather than whenever the compiler chooses to drop the object. the timing of when a mutex is dropped / reopened doesn't typically matter because it's just a software concept, but CS assertion and deassertion very much does.

Copy link
Member

@eldruin eldruin left a comment

Choose a reason for hiding this comment

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

Nice work! this looks pretty cool.
A few questions:

  • While I like the idea of closing the transaction when falling out of scope, do you have any plan on how to inform the driver/what to do about a CS pin deasertion error on drop() (if not using a done(mut self) method like @ryankurte suggested)?
  • Any reason why a similar approach would not work for I2C?

I see the point of people leaving the transaction open while doing something else, but given the popularity of this way of holding onto mutexes with guards and the fact that the SPI bus cannot be used for anything else as long as the transaction is not completed, it would be fine for me. The peripheral would just be listening to silence for longer than necessary. Attempting to create a new transaction should result in a compilation error.

Some TODOs:

  • Ensure that implementations for &mut T do not introduce problems like those highlighted in Draft: alternate ManagedCS trait #350
  • Ensure it is not possible to call transaction twice before the first one is dropped.
  • For async, ensure it is not possible to call transaction again while a transaction is still ongoing but an operation is being awaited.
  • Since it is a whole new concept it would be especially important to provide PoC implementations for HAL, drivers and bus mutexing.
  • Wait for stable GATs ☕

@Dirbaio
Copy link
Member Author

Dirbaio commented Jan 21, 2022

with this approach the CS is effectively scoped to the parent function rather than the transaction and requires explicit overriding with manual drop to maintain ordering, which would seem to make the API easier to misuse / opens opportunities for bugs like not closing and re-opening the transaction or not closing the transaction prior to moving on.

They're scoped to the parent block, not function. You can also add explicit {} braces to force dropping earlier, instead of drop(x). Also, many times the block scope naturally aligns with what you want to do, for example drivers often have read_reg, write_reg fns that do just that, so the transaction gets dropped at the right place.

While I like the idea of closing the transaction when falling out of scope, do you have any plan on how to inform the driver/what to do about a CS pin deasertion error on drop() (if not using a done(mut self) method like @ryankurte suggested)?

Oh, this is a problem indeed. done would work, but there's no way to enforce the user calls it, so you still have to do something on drop, probably panicking. Not great. Maybe it's not that bad because fallible GPIO is very uncommon...? Still, I don't like that there are two ways to accomplish the same thing (done and dropping) with non-obvious pros and cons.

Perhaps the closure is better after all. The analogy with Mutex/RefCell breaks down because dropping Transaction causes I/O side effects which are fallible, while dropping Mutex/RefCell guards doesn't...

Any reason why a similar approach would not work for I2C?

It'd work too, I guess? Same for the closure version. i2c has the same problems outlined in #299 so the device/bus split would help a lot there too. Same for the closure version if we decide to go with that. It'd be nice if i2c+spi transactions were consistent. i2c is a bit more picky with the repeated-start stuff etc, that might make it more complicated.

Ensure that implementations for &mut T do not introduce problems like those highlighted in #350

I would not do impl SpiBus for T where T: SpiDevice impls (which is the logical equivalent of the ones conflicting in #350). If you have an SpiDevice, you have to start a transaction to use it. Reasons:

  • It's conceptually wrong: Devices are not buses. If they were, you could give a device to a bus mutexer as if it was a bus, and have it mutexify that. You end up with two layers of mutexing, with the "inner" CS being auto-toggled at each read/write op, which makes no sense whatsoever.
  • It's relatively common to do multiple ops in the same transaction. Most devices are some form of "Write addr + read data, write addr+write data". Having it autostart the transaction if you want to do a single write is not that valuable IMO.
  • A driver might bound on SpiBus and do their own manual CS management. By doing so, it declares it wants exclusive ownership of the whole bus. The driver knows its manual CS management is correct thanks to owning the entire bus. If we had these impls, you'd be able to feed it an SpiDevice on a shared bus, which could cause transactions from other drivers to get interleaved in between without the driver knowing. This is the issue at SpiProxy is still unsound, even in a single thread Rahix/shared-bus#23 .
  • The impls do conflict as well, hehe 🙈
  • Ensure it is not possible to call transaction twice before the first one is dropped.
  • For async, ensure it is not possible to call transaction again while a transaction is still ongoing but an operation is being awaited.

These are already enforced by the 'a lifetime in Transaction: it borrows self, so you get a borrow checker error if you try.

  • Wait for stable GATs ☕

Yep... 😴

@eldruin eldruin added this to the v1.0.0 milestone Feb 14, 2022
@eldruin eldruin mentioned this pull request Feb 14, 2022
@Dirbaio
Copy link
Member Author

Dirbaio commented Feb 15, 2022

Pushed a new version!

  • Changed to use closures.
    • Removes the requirement on nightly-only GATs.
    • Solves the issue of propagating errors when deasserting CS.
  • Removed ManagedCs trait. After thinking about it more, I think the "device" abstraction is enough, and having an additional ManagedCs trait would be counterproductive / too restrictive.
    • For example: a driver for a chip with CS will require ManagedCs. However, the user might want to tie CS low on the PCB if they're not planning on sharing the bus. The CS pin is there, just not accessible from the MCU. The user would be unable to use the driver, unless doing hacks like "fake" ManagedCs impls that don't actually manage CS.
  • Changed SpiDevice, SpiDeviceRead, SpiDeviceWrite from traits to trait aliases. The problem with them being traits is the Device impls must explicitly impl them. We don't want that, we want them to be just a "pre-made set of trait bounds", which is exactly what trait aliases are. They're nightly-only, so perhaps it's best to not include them for now, and have drivers add bounds like this instead: where T: SpiDeviceBase, T::Bus: SpiBus.

I also wrote a working end-to-end test case here. Targets the Raspberry Pi Pico RP2040 with the Pico-ResTouch-LCD-2.8. It's the perfect use case because it has an SPI display and an SPI resistive touch sensor on the same SPI bus with separate CS pins.

  • The HAL impl stays nice and small, it only cares about the Bus part. It doesn't have to worry about Device stuff.
  • mod shared_spi impls SpiDevice on top of SpiBus+OutputPin, in a HAL-independent way. This is something that could go in crates like shared_bus.
  • mod touch implements the touch driver, generic on a SpiDevice. This shows how a small simple driver would look like. The code is simplified thanks to Device dealing with CS for us. It also shows doing multiple transfers in the same transaction with the new API (this would be done with the Transactional trait before, which this PR removes because it's no longer needed.)
  • mod my_display_interface contains the display interface impl, generic on a SpiDeviceWrite. Of note, this is a use case of the write-only SPI traits.
  • the main code ties everything together: Creates the SPI bus with the HAL, creates the two Devices with their CSs, and gives them to the drivers.

It all seems to work nicely together! :D

Open questions:

  • Do we want to add a "CS" variant to ErrorKind?
  • Do we want to add pre-made SpiDevice impls? Probably yes, at least for the "exclusive" case (no sharing), with and without CS.

@Dirbaio Dirbaio marked this pull request as ready for review February 15, 2022 18:48
@Dirbaio Dirbaio requested a review from a team as a code owner February 15, 2022 18:48
@Dirbaio Dirbaio changed the title Draft: Yet another take on SPI transaction + ManagedCS SPI: split into "device" and "bus" traits, to allow sharing buses Feb 15, 2022
@Dirbaio Dirbaio changed the title SPI: split into "device" and "bus" traits, to allow sharing buses SPI: split into device/bus, allows sharing buses with automatic CS Feb 15, 2022
@ryankurte
Copy link
Contributor

ryankurte commented Feb 15, 2022

approach looks pretty good to me! though still a couple of questions...

i don't think the device vs bus nomenclature is super clear, i think the biggest complaint is that we're going to end up with situations where we're talking about (a driver for) an spi device (eg. a radio) with bounds on SpiDevice.

i think we can resolve this with better module and per-function documentation wrt. the SpiDeviceX and SpiBusX functions to describe their purpose / use, and i'd be in favour of s/SpiDevice/Spi/g in the trait names since that's more consistent with other e-h traits and what most people will see.

Changed SpiDevice, SpiDeviceRead, SpiDeviceWrite from traits to trait aliases. The problem with them being traits is the Device impls must explicitly impl them.

agreed that (for now at least) we're going to need to drop the trait aliases... we could replicate this behaviour with marker traits and bounded default implementations, though they conflict with &mut T impls and i'm not sure whether it would be breaking to swap to aliases later. again i think documentation is probably the solution (show people exactly how to use em).

For example: a driver for a chip with CS will require ManagedCs. However, the user might want to tie CS low on the PCB if they're not planning on sharing the bus. The CS pin is there, just not accessible from the MCU. The user would be unable to use the driver, unless doing hacks like "fake" ManagedCs impls that don't actually manage CS.

i am pretty sure this is still necessary. the idea of ManagedCS is for drivers (hal consumers) to signal that they expect CS to be managed at a level below them, the only way to write that's abstract over software or hardware CS is to push that requirement out of the driver.

@Dirbaio
Copy link
Member Author

Dirbaio commented Feb 15, 2022

i think we can resolve this with better module and per-function documentation wrt. the SpiDeviceX and SpiBusX functions to describe their purpose / use

Agreed, will add more docs.

and i'd be in favour of s/SpiDevice/Spi/g in the trait names since that's more consistent with other e-h traits and what most people will see.

I think "SpiDevice, SpiBus" is the clearest. At least clearer than "Spi, SpiBus". What's "a SPI"?

If we do the Device/Bus thing for i2c as well, then there's no more consistency issues: the "bus" concept only applies to i2c and spi.

I don't agree SpiDevice is what most people will use:

  • HALs will impl SpiBus, and rarely touch SpiDevice.
  • There's still use cases for drivers using SpiBus (see below).

agreed that (for now at least) we're going to need to drop the trait aliases... we could replicate this behaviour with marker traits and bounded default implementations, though they conflict with &mut T impls and i'm not sure whether it would be breaking to swap to aliases later. again i think documentation is probably the solution (show people exactly how to use em).

Blanket impls don't work, yeah... I'll remove them, then add more docs.

i am pretty sure this is still necessary. the idea of ManagedCS is for drivers (hal consumers) to signal that they expect CS to be managed at a level below them, the only way to write that's abstract over software or hardware CS is to push that requirement out of the driver.

I would argue SpiDevice already very strongly implies there is a CS pin. A SpiDevice impl on a shared bus without a CS pin is broken. Perhaps we can simply document that SpiDevice MUST have a CS pin? For edge cases like "there's a CS but it's tied down in the PCB" the user can impl their own as an escape hatch, I don't think it's that common..?

Drivers that want an SpiDevice intentionally without CS shouldn't be a thing. These kinds of drivers should use SpiBus instead:

  • Drivers that manually manage CS. Manually managing CS on a shared bus is impossible to do correctly.
  • Drivers for chips that don't have a CS at all (do these exist?). It's impossible to put them on a shared bus at all.
  • Drivers that want to abuse SPI for "cursed" stuff like WS2812B waveform generation, weird bitbangings, logic analyzers (sampling the MISO pin at a fixed interval fast)

@ryankurte
Copy link
Contributor

ryankurte commented Feb 16, 2022

I don't agree SpiDevice is what most people will use:

sure, but the number of HALs << the number of drivers << the number of applications. HAL authors are already deep in the technical side, and i would expect orders of magnitude more drivers and applications, so it seems worth weighting our solutions towards that end.

I would argue SpiDevice already very strongly implies there is a CS pin. A SpiDevice impl on a shared bus without a CS pin is broken. Perhaps we can simply document that SpiDevice MUST have a CS pin? For edge cases like "there's a CS but it's tied down in the PCB" the user can impl their own as an escape hatch, I don't think it's that common..?

i think we are looking at this from the opposite direction. so your expectation is that HALs will provide Spi::with_sw_cs and Spi::with_hw_cs(..., pin: Pin) functions so they cannot be constructed without some CS control?

because in many cases you will choose whether to use the hw implementation based on the functionality you require, so can't be required to use the hw approach. and building on that what happens if you had a cursed situation like CS via GPIO expander, how would you model this?

Drivers that manually manage CS. Manually managing CS on a shared bus is impossible to do correctly.

this is the whole point of SpiDevice and transaction... once you're in the closure you have exclusive access to the bus to do what you will with CS (or any other IOs)?

@Dirbaio
Copy link
Member Author

Dirbaio commented Feb 16, 2022

Pushed new version

  • Removed trait aliases
  • Renamed SpiDeviceBase to SpiDevice
  • Added ExclusiveDevice. Impls a SpiDevice out of a SpiBus and a OutputPin, with no bus sharing.
  • Added docs.

@ryankurte hopefully the added docs clarifies the confusion. The TLDR is:

  • SpiBus = exclusive ownership of the whole bus. NEVER manages CS. Implemented in HALs
  • SpiDevice = ownership of a single device in a possibly-shared bus. ALWAYS manages CS. Implemented in either:
    • Using Hardware CS, in the HALs.
    • Using software CS, in either
      • embedded-hal, the ExclusiveDevice
      • Third-party crates, like shared-bus.

Since SpiDevice ALWAYS manages CS, there's no need for a ManagedCs marker trait. There's no such thing as a "SpiDevice without CS", you just use SpiBus instead.

@Dirbaio
Copy link
Member Author

Dirbaio commented Feb 16, 2022

ugh, MSRV is 1.46+ but #[doc = include_str!(...)] is 1.54+ ... What do we do?

@ryankurte
Copy link
Contributor

Since SpiDevice ALWAYS manages CS, there's no need for a ManagedCs marker trait. There's no such thing as a "SpiDevice without CS", you just use SpiBus instead.

alright i think that's clearer. and if your hardware does CS but you don't use it you just ignore it?

ugh, MSRV is 1.46+ but #[doc = include_str!(...)] is 1.54+ ... What do we do?

would it work to chuck the images here then link to them instead of embedding for now?

@Dirbaio
Copy link
Member Author

Dirbaio commented Feb 16, 2022

alright i think that's clearer. and if your hardware does CS but you don't use it you just ignore it?

Yep, The HAL would only enable hardware-CS if you ask for it. You can just use the SpiBus part.

would it work to chuck the images here then link to them instead of embedding for now?

👍

@Dirbaio
Copy link
Member Author

Dirbaio commented Feb 18, 2022

The "configure bus" closure is super neat! That's a valid SpiDevice impl (allowed by the trait contract) indeed.

I think it's best to leave the issue of configuring the bus to external crates, so we don't lock ourselves to a particular solution. One such solution is HALs providing set_freq, set_mode methods and shared-bus providing the "configure bus" closure as you say!

In the future we could add traits for set_freq, set_mode so SpiDevice impls can do that in a HAL-independent way, but I don't think it's needed now.

@Dirbaio
Copy link
Member Author

Dirbaio commented Feb 18, 2022

New version

  • Added helper methods to SpiDevice to do transactions with single transfers

Copy link
Contributor

@GrantM11235 GrantM11235 left a comment

Choose a reason for hiding this comment

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

The convenience methods should be generic over the word type

src/spi/blocking.rs Outdated Show resolved Hide resolved
src/spi/blocking.rs Outdated Show resolved Hide resolved
src/spi/blocking.rs Outdated Show resolved Hide resolved
src/spi/blocking.rs Outdated Show resolved Hide resolved
Co-authored-by: GrantM11235 <GrantM11235@gmail.com>
@Dirbaio
Copy link
Member Author

Dirbaio commented Feb 23, 2022

New version

  • Make SpiDevice helper methods generic over word type. (Thanks @GrantM11235, very nice catch! :D)

Copy link
Contributor

@ryankurte ryankurte left a comment

Choose a reason for hiding this comment

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

still lgtm, shall we get this done?

Copy link
Contributor

@almindor almindor left a comment

Choose a reason for hiding this comment

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

Yes please! We can incorporate this into riscv-rust/e310x-hal#42 and test things out soon.

Copy link
Member

@eldruin eldruin left a comment

Choose a reason for hiding this comment

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

Looks great to me, thank you everybody for the interesting discussion!
@therealprof ?

Copy link
Contributor

@therealprof therealprof left a comment

Choose a reason for hiding this comment

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

Looks like we're all agreed.

Let's get this merged. 🎉

bors r+

@bors bors bot merged commit 6fda0f2 into rust-embedded:master Feb 23, 2022
bors bot added a commit that referenced this pull request Feb 24, 2022
365: spi: allow impls returning early, add flush.  r=therealprof a=Dirbaio

Fixes #264 
Depends on #351 

- Document that all methods may return early, before bus is idle.
- Add a `flush` method to wait for bus to be idle.

The alternative is to document that methods MUST wait for bus idle before returning, but that would be bad for performance IMO. Allowing early return means multiple successive writes can "overlap" so that bytes come out back-to-back without gaps, which is great for workloads with tons of small writes, like SPI displays with `embedded-graphics`.

Drivers using `SpiDevice` won't have to worry about this, it'll Just Work since Device impls must flush before deasserting CS. Drivers using SpiBus will have to care if they coordinate SPI activity with other GPIO activity.

Co-authored-by: Dario Nieuwenhuis <dirbaio@dirbaio.net>
bors bot added a commit that referenced this pull request Mar 10, 2022
347: async: add SPI r=eldruin a=Dirbaio

This is an exact mirror of the blocking SPI trait (including the proposed changes in #351 ), except the following differences:

- `W: 'static` is required everywhere, otherwise complicated lifetime bounds are required in the future GATs.


Co-authored-by: Dario Nieuwenhuis <dirbaio@dirbaio.net>
bors bot added a commit to embassy-rs/embassy that referenced this pull request Mar 10, 2022
627: Update Rust nightly, embedded-hal 1.0, embedded-hal-async. r=Dirbaio a=Dirbaio

Includes the SpiDevice/SpiBus split.  rust-embedded/embedded-hal#351
Includes the GAT where clause location change. rust-lang/rust#89122


Co-authored-by: Dario Nieuwenhuis <dirbaio@dirbaio.net>
bors bot added a commit to embassy-rs/embassy that referenced this pull request Mar 10, 2022
627: Update Rust nightly, embedded-hal 1.0, embedded-hal-async. r=Dirbaio a=Dirbaio

Includes the SpiDevice/SpiBus split.  rust-embedded/embedded-hal#351
Includes the GAT where clause location change. rust-lang/rust#89122


Co-authored-by: Dario Nieuwenhuis <dirbaio@dirbaio.net>
bors bot added a commit that referenced this pull request May 19, 2023
461: spi: remove write-only and read-only traits. r=Dirbaio a=Dirbaio

When introducing the Bus/Device split in #351, I kept the ability to represent "read-only" and "write-only" buses, with separate `SpiBusRead`, `SpiBusWrite` buses. This didn't have much discussion, as it was the logical consequence of keeping the separation in the already existing traits (`Read`, `Write`, `Transfer`, ...).

Later, in #443, when switching from the closure-based API to the operation-slice-based API, this required adding `SpiDeviceRead`, `SpiDeviceWrite` as well, because you can no longer put a bound on the underlying bus with the new API.

This means we now have *seven* traits, which we can reduce to *two* if we drop the distinction. So, is the distinction worth it?

I've always been on the fence about it, now I'm sort of leaning towards no.

First, using write-only or read-only SPI is rare. 
- write-only SPI: for SPI displays you don't want to read from, or to abuse it to bitbang stuff like ws2812b,
- read-only SPI: some ADCs that can't be configured at all, you just clock out bits. Or even weirder abuses, like to build a logic analyzer

Second, for it to be useful HALs have to track "read-onlyness / write-onlyness" in the type signature, so a read-only SPI really only implements `SpiBusRead` and not `SpiBus`. HALs already have enough generics. For example, Embassy HALs don't implement the distinction (you can create MOSI-only or MISO-only SPIs, but they all impl the full `SpiBus`, because I didn't want to make the types carry pin information).

Third, it makes the API easier to use. Simpler, users only have to import one trait, docs have all the methods in one place. Much less boilerplate to impl the traits (look at how shorter `embedded-hal-bus` gets!).

So I think we should remove them.

Co-authored-by: Dario Nieuwenhuis <dirbaio@dirbaio.net>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
S-waiting-on-review Review is incomplete T-hal
Projects
None yet
Development

Successfully merging this pull request may close these issues.

8 participants