Hello, I have a design question I'm looking for input on. Warning, somewhat long question.
Context
I'm experimenting rewriting a driver for USB mice (not really a driver, more just a configuration tool in user space) in Rust. There are certain features, like:
- Get polling rate
- Set polling rate
- Set LED effects (Single Color, Wave, ...) on different LEDs (some support just 1, others more)
- etc..
There are a ton of devices, many of which don't support the same feature set. Additionally, for the same feature, many groups of devices have different implementations. Some are minor differences (a single byte change) and some major (needing multiple USB messages sent differently).
The goal is to have an API that can expose supported features for a device, and then also functions implementing those features.
Background
The current driver in C implements a feature with a switch/case
handling different device's behaviors (example). Supported features per device are exposed with a switch/case
with fallthrough (example) and a build script that manually extracts those names from the source code for use from Python.
My tool is written in Rust and supports the device I have. I want to experiment with supporting more, and I have two approaches I've thought of:
Approach 1
Specify supported features per device at compile time. Implement features with match
statements essentially like the existing C driver:
async fn chroma_effect_breath(/*...*/) {
match self.device.id {
SOME_MOUSE | OTHER_MOUSE | OTHER_MOUSE2 => {/*...*/},
SOME_MOUSE2 => {/*...*/},
_ => {/*...*/}
}
}
/* Specify supported features per device somewhere - not used internally, kept up to date manually */
const DEVICE_FEATURES = PerfectHashMap {
/*...*/
}
+ It's simpler (and more readable?)
- Adding a device requires changes to every feature's implementation (even if just a match arm)
- Device implementation is spread out across every feature's code
- Supported features would be specified separately and not tied directly to implementation, which could have bugs letting them be out of sync (ex: feature is specified as supported but forgot to add a match arm for the device to use the correct impl, so it doesn't work)
Approach 2
Specify supported features per device at compile time. Internally, those features also point to their implementations. This would look something like
struct FeatureSet {
get_dpi: Option</*fn pointer*/>,
set_dpi: Option</*fn pointer*/>,
chroma_breath: Option</*fn pointer*/>,
supported_leds: &[LED],
}
async fn chroma_effect_breath(/*...*/) {
if let Some(impl) = DEVICE_FEATURES[self.device.id].chroma_breath {
/* use impl */
} else {
/* err - unsupported feature */
}
}
// Shared by some devices
fn chroma_effect_breath_impl_1 {}
// Shared by some devices
fn chroma_effect_breath_impl_2 {}
// Shared by other devices
fn chroma_effect_breath_impl_default {}
/* global using pfh */
const DEVICE_FEATURES = PerfectHashMap {
DEATHADDER => FeatureSet{}, // probably a macro to make this too
/*...*/
}
This could also be traits like Dpi
, Chroma
, then structs that implement those, and FeatureSet
would contain Option
's holding dyn Dpi
, etc.
+ Adding a new device requires one localized change to the global map (assuming no new USB behavior)
+ Device behavior is visible in one place
+ Supported features API and actual implementation can't become out of sync -> less room for bugs
- More complex, macros, etc.
- Less readable?
Both approaches would have an API for querying supported features and the same API for actually doing things.
Question
Any input on best design decision here? Or even a different design too.