Adding a simple feature to zencan brought up some thorny topics.
The original problem
In zencan, objects have a default value. Of course they do -- they have to start with something! Often times that's just 0, but some objects should be initialized with particular values. For "application objects" -- the custom objects created in the application-specific "device config" file -- the default value can be specified as part of the object definition. However, some objects are defined automatically for the application by the code generation system, including the PDO configuration objects. Until just recently, there wasn't any way to specify the defaults for these.
PDO config objects (there are two for each PDO, called the "comm", and "mapping" objects) control how PDO messages are sent and received, and which data objects the PDO payload bytes are mapped to. Up to now, I've configured PDOs by writing to the comm and mapping objects via the SDO server, and then saving those values to flash. That's great and this type of run-time configurability is a key motivator for the project, but for some applications it makes a lot of sense to boot up with pre-configured PDOs, and so zencan needed a mechanism to specify defaults.
So, easy enough: I'll add some attributes in the [pdos] section of the device config to set the
defaults, I figured. I already have a schema used in the "node configuration" files, which are used
by zencan-client to do the run-time configuration. It looks like this:
# Configure TPDO1 to send on CAN ID 0x200
[tpdo.1]
enabled = true
cob = 0x200
transmission_type = 254
# Fill the PDO data with 4 bytes from object 0x2000sub1 and 4 more from 0x2000sub2
mappings = [
{ index=0x2000, sub=1, size=32 },
{ index=0x2000, sub=2, size=32 },
]
"This will be quick and easy", I figured. It was not.
Objects in zencan are defined as static variables in generated code. Then references to all the
objects are put into a list of ODEntry objects. It looks something like this:
pub static RPDO_COMM_OBJECTS: [PdoCommObject; 1usize] = [
PdoCommObject::new(&NODE_STATE.rpdos()[0usize]),
];
pub static OBJECT1000: Object1000 = Object1000::default();
pub static OBJECT1001: Object1001 = Object1001::default();
pub static OD_TABLE: [ODEntry; 3usize] = [
ODEntry {
index: 0x1000,
data: &OBJECT1000,
},
ODEntry {
index: 0x1001,
data: &OBJECT1001,
},
ODEntry {
index: 0x1400,
data: &RPDO_COMM_OBJECTS[0usize],
},
]
Defaults are just rust initializers. And this is where the first issue popped up.
Non-const init
The first problem you hit here is that when you write to the mapping object, you write a u32 referencing the object-to-be-mapped by index and sub-index. But what the PDO actually stores is a reference to the object, so that it doesn't have to search through the table every time it has to load/store data. The data stored for each mapping looks like this:
/// Data structure for storing a PDO object mapping
struct MappingEntry {
/// A reference to the object which is mapped
pub object: &'static ODEntry<'static>,
/// The index of the sub object mapped
pub sub: u8,
/// The length of the mapping in bytes
pub length: u8,
}
Strictly speaking, the code generation "knows" the name and ID of every object, so it could probably generate const MappingEntry objects with the correct static references. But this is going to really complicated the already too complicated code generation code, and also yet another datatype that should be private has to become public, because the generated code now has to know more about the internals of the PDO object implementation.
But there's a bigger reason this isn't worth pursuing: Eventually zencan needs to support restoring defaults at run-time for all objects. So objects need to store a live working value as well as the default value for re-initializing.
Duplicating memory
This raises a new concern: to restore at default, there is a requirement that both the default value and the live value be stored somewhere. But, ideally, the default value will be stored exactly once, and only in flash, while the live value will also be stored exactly once, and in RAM.
This led down a new rabbit hole of testing what scenarios rust will place data into the
data section vs bss. The data section is part of the program flash size and static RAM
allocation -- it is copied to RAM to initialize the variables. The bss section is just zero'd, so it
doesn't have corresponding bytes in flash, making the program size smaller. In order for an object
to live in bss, it needs to be initialized with all zeros. Incidentally, if one of the attributes of
your object is a reference, it cannot be initialized with zeros.
There are other concerns, like in the case of PDOs, they have to allocate RAM storage for the maximum allowed number of mappings, but in flash, it would be better if the defaults were stored as variably-sized slices, so only the number of mappings initialized in the default have to take up program space.
I suspect that in the future, this will be revisted, and I think the path is going to be to enforce
that the object and the object dict are private and can only be accessed via a getter function which
can ensure a one-time lazy initialization process is observed. This will allow things like
MaybeUnininit<&'static Pdo> where needed, to ensure that objects can go into BSS.
But as spun in a few circles on this, I realized that I was falling into a classic scope creep trap that I am very susceptible to: instead of finishing the PDO default feature I set out to implement I was refactoring all object initialization which could better be done in a follow-on PR. So I'm leaving this for later, and accepting that the first pass at PDO defaults may use more flash bytes than strictly necessary.
Defaults as a function of runtime state
It is standard in CANOpen devices for the default PDO COB IDs to be based on the configured node ID,
e.g. $NODEID+0x180. This makes a lot of sense: you might have a device that puts out some PDOs by
default, and you might put more than one of those devices on the network, so zencan should support
this! This means that the default we wish to load isn't even known at compile time.
Also! The main unit of information is the object data -- i.e. the bytes stored in the objects. This
is what remote clients can access via the SDO server, and this is what is stored in flash when
settings are persisted. So lets say our node ID is 1 and the default initializer has set the COB ID
to 0x181. Now, lets say we save all the objects to flash. Then we change the node ID to 2 and reset.
Should the new COB ID be 0x182? That's what I would expect! The user never changed it from the
default, which is $NODEID+0x180, and now $NODEID=2, so 0x182. But what we read from flash for
the COMM object is 0x181. Did the user write this value, or was it a default calculation? We now
have to somehow distinguish these cases.
We could track whether the default COMM value has been changed, and then skip storing it to flash if it has not been changed from the default. That would help. But as things are setup currently, the object can't distinguish between a default being written and a value written from e.g. an SDO client.
Speaking of SDO clients: ideally, when they read the PDO configuration they could tell the
difference between a COB ID which is set to 0x181 and a COB ID which is set to $NODEID+0x180.
Ideally they'd even be able to configure a COB ID to use a node ID offset at runtime. If I was
designing this protocol, I'd put a flag in the PDO COMM object called add_node_id to control this
behavior so that the state is encoded in the object.
As I have done before when I have found the CANOpen protocol confounding, I went and and looked at what the most popular C implementation does (relevant file).
/* if default CAN-ID is written, store to OD without Node-ID */
if (CAN_ID == PDO->preDefinedCanId) {
(void)CO_setUint32(bufCopy, COB_ID & 0xFFFFFF80U);
}
/* If default CAN-ID is stored in OD (without Node-ID), add Node-ID */
if ((CAN_ID != 0U) && (CAN_ID == (preDefinedCanId & 0xFF80U))) {
CAN_ID = preDefinedCanId;
}
To understand the above snippets, you should know that (sticking with $NODEID=1 and
COB_ID=$NODEID + 0x180 as an example) preDefinedCanId is set to 0x181. If the value encoded in
the object is 0x180, it always adds the node id. If a user writes 0x181, it assumes that what
the user really meant was $NODEID + 0x180 and stores 0x180 to the object. So if I try to write
0x181, it gets converted to 0x180. And when the stored value is 0x180, that is interpreted as the
default $NODEID+0x180.
I don't love this. It's a special rule, and it's a surprising behavior. It also makes it
impossible to use 0x180 as a COB ID, or to set the COB ID to 0x181 irrespective of the node ID. If
I write 0x181 to the COB ID, I expect that to be the new COB ID even if I change the node ID. It's
not hard to come up with a use case where this actually becomes a problem:
Imagine some device expects a particular message on 0x181 because historically it was sent by node 1 using the default. I come along and decide that I'm going to manage my network by serial number, and I don't care about what node IDs are assigned, so I write a controller which enumerates the nodes via LSS, and assigns them arbitrary IDs, but loads configurations into each node based on serial number. I want a node with a particular serial number to send the 0x181 PDO, so I set it up that way, only to find that when assigned a random node ID it is suddenly sending the PDO on a different ID!.
Also I think ideally it should be possible to set default PDO IDs which do not have the node ID added. A realistic use case is: "this type of device always sends this message, regardless of the assigned node ID". But this precludes any default initialization scheme which is limited to restoring object values, because the answer to the question "should I add the node ID" is not encoded anywhere in the object values. This is the same situation I'm sure the CANOpenNode author(s) were faced with when they decided to enact the special rules above.
Options
Once again I am tempted to throw out the CANOpen spec and go my own way, but I'm not quite there yet. Some options considered:
- Use COB_ID=0 as a special value indicating to use the default. The default has to be stored outside of the object data, and serialization to persistent storage has to be prevented when the value is 0 (because serializing just reads the object, and reading the object returns the computed value).
- Adopt the special rules of CANOpenNode.
- Drop support for adding node ID to PDO IDs altogether.
- Add a new sub object to the PDO comm object to store a flag for "add the node ID" behavior.
I think 4 is clearly the ideal. Doing anything else feels like I am compromising for the sake of an old protocol, but also, if I add a sub object then someone, someday is going to use zencan to try to do a CANOpen compliance certification and will fail and be annoyed with me.
Number 2 and 3 I think are non-starters. The NODE ID offsets are standard and useful, and the special rules of number 2 are just too weird of a corner case for my taste.
So number 1 seems like the most plausible. It allows assigning any ID to the PDO via SDO, and it
allows defaults to include the node ID or not. It does not allow an SDO client to distinguish
between default and non-default state in general, and it does not allow setting arbitrary $NODEID + x
values for the COB ID, but this is not an immediate problem at least. Also, I think it is still
relatively easy in the future to add an optional extension sub-object to the COMM object with the
add_node_id flag if the need ever really does arise.