Skip to content

Add first-class PDO discovery and manipulation #317

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

Draft
wants to merge 11 commits into
base: main
Choose a base branch
from

Conversation

theol0403
Copy link

@theol0403 theol0403 commented Jul 5, 2025

Thanks again for this awesome crate!

When learning ethercrab (and ethercat), it was difficult and unclear to understand exactly how PDOs are managed, how to access them, and specifically the byte mappings to the PDI input/output slices. Not only this, but the ergonomics of dealing with raw byte slices are poor and brittle.

When digging into the crate, I realized that we already have all the information we need to provide a richer PDO interface, through the sm bit length discovery we conduct during init.

This PR aims to provide a clean and easy way to interact with PDOs. It does this by:

  • collecting discovered PDOs from EEPROM/CoE into a map stored in the state struct
  • simplifying pdi.rs by delegating PdiIoRawWriteGuard to PdiWriteGuard (ditto for read) and simplifying ownership
  • adding fn pdo_raw on PdiWriteGuard (and read) which produces a slice based on PDO mappings

I believe this is the first step to richer object dictionary support and flexible device support. My usecase here is connecting to varous Ds402 and having them work out of the box (provided their default configurations provide the required PDO mappings) without needing to know their PDI byte mappings during compile time.

let mut io = subdevice.io_raw_mut();
let inputs = io.inputs();
let pos = inputs.pdo_raw(0x6060, 0).unwrap()[0];

I'd love to hear your thoughts on this design.

The largest challenge I am running into: stack space. Currently, heapless::FnvIndexMap<(u16, u8), Range<usize>, 32>; is 904 bytes on a macbook pro. This, multiplied by two, crashes the stack if you have many devices enabled.

  • dropping Range<u16> reduces to 456 bytes, as our PDI will never be that huge, but then the slice accessors become more messy (acceptable)
  • switching to LinearMap reduces to 264 bytes, which is better, but has a runtime cost

Implementing both these approaches still overflow the stack in some instances.

Perhaps the correct solution is to move PDI mapping config into static storage along with the PDI itself, though I don't really know how to do this cleanly. Alternatively, we could add an alloc feature flag for use in any future object-dictionary related features. Any suggestions?

Todo:

  • Add pdo_read<T> and pdo_write<T> non-raw methods to PdiWriteGuard
    • Rename io_raw_mut to io_mut, as it will support raw and non-raw access? Same with the other raw methods.
    • If we're making breaking changes, change the implicit [u8] deref into a more explicit as_slice()? That way we can have inputs.as_slice or inputs.pdo_read.
    • Completely remove pdo_raw public access, as a byte slice implements WireRead/Write so can be used with pdo_read/write?
  • Add all_pdos which returns a map of all mapped PDOs and their corresponding byte slices? Not needed with access to io_segments.
  • Verify changes to pdi.rs don't violate memory/sync safety
  • Fix large stack usage
  • Do we actually need any unsafe code in the guards if we just use self.lock.get_mut()[self.range.clone()] on the read-only guards too?
  • Docs
    • Pdo mapping docstring examples
    • Use in examples

@theol0403 theol0403 marked this pull request as draft July 5, 2025 09:44
@theol0403
Copy link
Author

theol0403 commented Jul 6, 2025

Reduced stack size by using pub type PdoMapping = heapless::LinearMap<(u16, u8), (u16, u8), 64>;, though not sure if the O(N) lookup is acceptable. The tests no longer crash the stack, although it is close. Might still need to investigate static storage or alloc.

Edit: will add a trivial alloc flag, let me know if this is acceptable.

@jamwaffles
Copy link
Collaborator

Hey, thanks for digging into this a bit. I started some work on this a while ago and have just opened #318 in a very rough state to keep an eye on it. I think I'd prefer to go in my own direction for this kind of functionality, but I'd be very interested to hear your thoughts on that PR when it's in a better state.

@theol0403
Copy link
Author

theol0403 commented Jul 8, 2025

I believe our two approaches are mostly orthogonal and likely complimentary.

Currently, to access the pdi, one must do the following:

let mut i = subdevice.inputs_raw_mut();
// byte slice indices require knowledge of ethercat internals and device-specific config
let status = u16::from_le_bytes(i[4..=5].try_into().unwrap()); 

This code must be hardcoded per device. My usecase, and this PR, is to be able to do the following:

let mut io = subdevice.io_raw_mut();
// read status word regardless of config
let mut inputs = io.inputs();
let status: u16 = inputs.pdo_read(0x6041, 0).unwrap();
// write control word regardless of config
let mut outputs = io.outputs();
if let Err(e) = outputs.pdo_write(0x6040, 0, 6u8) {
    println!("PDO isn't mapped in PDI image: {e:?}");
    // fallback to sdo
    subdevice.sdo_write(0x6060, 0, 6u8);
}
Or automatically print all input PDOs for debugging:
let inputs = io.inputs();
for ((index, sub_index), (_, len)) in subdevice.io_segments().tx_pdos.iter() {
    #[allow(dead_code)]
    #[derive(Debug)]
    enum Fmt {
        I8(i8),
        I16(i16),
        I32(i32),
        I64(i64),
    }

    let fmt = match len {
        1 => Fmt::I8(inputs.pdo_read(*index, *sub_index).unwrap()),
        2 => Fmt::I16(inputs.pdo_read(*index, *sub_index).unwrap()),
        4 => Fmt::I32(inputs.pdo_read(*index, *sub_index).unwrap()),
        8 => Fmt::I64(inputs.pdo_read(*index, *sub_index).unwrap()),
        _ => panic!("Unsupported bit length"),
    };

    log::info!(
        "SubDevice {:#06x} index: {:#04x}, subindex: {:#04x}, value: {:?}",
        subdevice.configured_address(),
        index,
        sub_index,
        fmt
    );
}

This is done completely transparently to the user, with no extra code (or config). Other than the mapping stack storage, this is also done completely for free, piggybacking on the sm discovery we already do.

In my usecase, I have multiple servos from different manufacturers connected to the same bus. They all have different FMMU configs, with io byte indexes in different places. However, they are guaranteed to map, at some location, the crucial PDOs for DS402 operation.

Most importantly, their rx/tx pdo mappings are read-only and cannot be configured. From my understanding, your upcoming work is to allow users to explicitly configure their sm configs. However, this will not help with my usecase as writing config to the device will panic.

Instead, this PR allows users to write the same generic code without needing any device-specific hardcoded logic (or ESI), using greatly improved ergonomics. This PR also DRYs the pdi.rs a little bit. While I think providing the ability for manual config in #318 is very useful, it solves a different usecase, and the mechanisms for named PDI access can be complimentary.

Feel free to take as many or as little as the ideas from this as you want. I'll maintain my fork for my usecase until/if equivalent functionality is available in main.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants