diff --git a/.github/workflows/build-deploy-docs.yml b/.github/workflows/build-deploy-docs.yml
index f2832f6db..e92c5787e 100644
--- a/.github/workflows/build-deploy-docs.yml
+++ b/.github/workflows/build-deploy-docs.yml
@@ -48,7 +48,7 @@ jobs:
- name: Build rustdoc docs
run: |
- cargo doc -p riot-rs --features bench,csprng,executor-thread,external-interrupts,hwrng,i2c,no-boards,random,threading,usb
+ cargo doc -p riot-rs --features bench,csprng,executor-thread,external-interrupts,hwrng,i2c,no-boards,random,sensors,threading,usb
echo "" > target/doc/index.html
mkdir -p ./_site/dev/docs/api && mv target/doc/* ./_site/dev/docs/api
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 3ce7bcf4d..42b1ed253 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -116,7 +116,7 @@ jobs:
# TODO: we'll eventually want to enable relevant features
- name: Run crate tests
run: |
- cargo test --no-default-features --features i2c,no-boards -p riot-rs -p riot-rs-embassy -p riot-rs-embassy-common -p riot-rs-runqueue -p riot-rs-threads -p riot-rs-macros
+ cargo test --no-default-features --features i2c,no-boards -p riot-rs -p riot-rs-embassy -p riot-rs-embassy-common -p riot-rs-runqueue -p riot-rs-sensors -p riot-rs-threads -p riot-rs-macros
cargo test -p rbi -p ringbuffer -p coapcore
# We need to set `RUSTDOCFLAGS` as well in the following jobs, because it
@@ -176,7 +176,7 @@ jobs:
- name: clippy
uses: clechasseur/rs-clippy-check@v3
with:
- args: --verbose --locked --features no-boards,external-interrupts -p riot-rs -p riot-rs-boards -p riot-rs-chips -p riot-rs-debug -p riot-rs-embassy -p riot-rs-embassy-common -p riot-rs-macros -p riot-rs-random -p riot-rs-rt -p riot-rs-threads -p riot-rs-utils -- --deny warnings
+ args: --verbose --locked --features no-boards,external-interrupts,sensors -p riot-rs -p riot-rs-boards -p riot-rs-chips -p riot-rs-debug -p riot-rs-embassy -p riot-rs-embassy-common -p riot-rs-macros -p riot-rs-random -p riot-rs-rt -p riot-rs-sensors -p riot-rs-threads -p riot-rs-utils -- --deny warnings
- run: echo 'RUSTFLAGS=--cfg context="esp32c6"' >> $GITHUB_ENV
- name: clippy for ESP32
@@ -206,7 +206,7 @@ jobs:
- run: echo 'RUSTFLAGS=' >> $GITHUB_ENV
- name: rustdoc
- run: RUSTDOCFLAGS='-D warnings' cargo doc -p riot-rs --features bench,csprng,executor-thread,external-interrupts,hwrng,i2c,no-boards,random,threading,usb
+ run: RUSTDOCFLAGS='-D warnings' cargo doc -p riot-rs --features bench,csprng,executor-thread,external-interrupts,hwrng,i2c,no-boards,random,sensors,threading,usb
- name: rustdoc for ESP32
run: RUSTDOCFLAGS='-D warnings --cfg context="esp32c6"' cargo doc --target=riscv32imac-unknown-none-elf --features external-interrupts,i2c,esp-hal/esp32c6,esp-hal-embassy/esp32c6 -p riot-rs-esp
diff --git a/Cargo.lock b/Cargo.lock
index 9ead0189c..9978a4d40 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3262,6 +3262,26 @@ dependencies = [
"serde-json-core",
]
+[[package]]
+name = "pin-project"
+version = "1.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf123a161dde1e524adf36f90bc5d8d3462824a9c43553ad07a8183161189ec"
+dependencies = [
+ "pin-project-internal",
+]
+
+[[package]]
+name = "pin-project-internal"
+version = "1.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4502d8515ca9f32f1fb543d987f63d95a14934883db45bdb48060b6b69257f8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.79",
+]
+
[[package]]
name = "pin-project-lite"
version = "0.2.14"
@@ -3610,6 +3630,7 @@ dependencies = [
"riot-rs-macros",
"riot-rs-random",
"riot-rs-rt",
+ "riot-rs-sensors",
"riot-rs-threads",
"riot-rs-utils",
"static_cell",
@@ -3822,6 +3843,17 @@ dependencies = [
"hax-lib",
]
+[[package]]
+name = "riot-rs-sensors"
+version = "0.1.0"
+dependencies = [
+ "defmt",
+ "embassy-sync 0.6.0",
+ "linkme",
+ "pin-project",
+ "riot-rs-macros",
+]
+
[[package]]
name = "riot-rs-stm32"
version = "0.1.0"
diff --git a/Cargo.toml b/Cargo.toml
index 873713c71..e27194a79 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -18,6 +18,7 @@ members = [
"src/riot-rs-nrf",
"src/riot-rs-random",
"src/riot-rs-rp",
+ "src/riot-rs-sensors",
"src/riot-rs-stm32",
"tests/benchmarks/bench_sched_flags",
"tests/benchmarks/bench_sched_yield",
@@ -85,9 +86,11 @@ riot-rs-boards = { path = "src/riot-rs-boards", default-features = false }
riot-rs-debug = { path = "src/riot-rs-debug", default-features = false }
riot-rs-embassy = { path = "src/riot-rs-embassy", default-features = false }
riot-rs-embassy-common = { path = "src/riot-rs-embassy-common" }
+riot-rs-macros = { path = "src/riot-rs-macros" }
riot-rs-random = { path = "src/riot-rs-random" }
riot-rs-rt = { path = "src/riot-rs-rt" }
riot-rs-runqueue = { path = "src/riot-rs-runqueue" }
+riot-rs-sensors = { path = "src/riot-rs-sensors" }
riot-rs-stm32 = { path = "src/riot-rs-stm32" }
riot-rs-utils = { path = "src/riot-rs-utils", default-features = false }
@@ -102,6 +105,7 @@ once_cell = { version = "=1.19.0", default-features = false, features = [
"critical-section",
] }
paste = { version = "1.0" }
+pin-project = "1.1.6"
static_cell = { version = "2.0.0", features = ["nightly"] }
[profile.dev]
diff --git a/src/riot-rs-macros/Cargo.toml b/src/riot-rs-macros/Cargo.toml
index 9bfd250be..f7134b322 100644
--- a/src/riot-rs-macros/Cargo.toml
+++ b/src/riot-rs-macros/Cargo.toml
@@ -32,3 +32,13 @@ trybuild = "1.0.89"
[lib]
proc-macro = true
+
+[features]
+# These features could be codegened
+max-reading-value-min-count-2 = []
+max-reading-value-min-count-3 = []
+max-reading-value-min-count-4 = []
+max-reading-value-min-count-6 = []
+max-reading-value-min-count-7 = []
+max-reading-value-min-count-9 = []
+max-reading-value-min-count-12 = []
diff --git a/src/riot-rs-macros/src/define_count_adjusted_sensor_enums.rs b/src/riot-rs-macros/src/define_count_adjusted_sensor_enums.rs
new file mode 100644
index 000000000..2c933f68e
--- /dev/null
+++ b/src/riot-rs-macros/src/define_count_adjusted_sensor_enums.rs
@@ -0,0 +1,134 @@
+/// Generates sensor-related enums whose number of variants needs to be adjusted based on Cargo
+/// features, to accommodate the sensor driver returning the largest number of values.
+///
+/// One single type must be defined so that it can be used in the Future returned by sensor
+/// drivers, which must be the same for every sensor driver so it can be part of the `Sensor`
+/// trait.
+#[proc_macro]
+pub fn define_count_adjusted_sensor_enums(_item: TokenStream) -> TokenStream {
+ use quote::quote;
+
+ #[allow(clippy::wildcard_imports)]
+ use define_count_adjusted_enum::*;
+
+ // The order of these feature-gated statements is important as these features are not meant to
+ // be mutually exclusive.
+ #[allow(unused_variables, reason = "overridden by feature selection")]
+ let count = 1;
+ #[cfg(feature = "max-reading-value-min-count-2")]
+ let count = 2;
+ #[cfg(feature = "max-reading-value-min-count-3")]
+ let count = 3;
+ #[cfg(feature = "max-reading-value-min-count-4")]
+ let count = 4;
+ #[cfg(feature = "max-reading-value-min-count-6")]
+ let count = 6;
+ #[cfg(feature = "max-reading-value-min-count-7")]
+ let count = 7;
+ #[cfg(feature = "max-reading-value-min-count-9")]
+ let count = 9;
+ #[cfg(feature = "max-reading-value-min-count-12")]
+ let count = 12;
+
+ let physical_values_variants = (1..=count).map(|i| {
+ let variant = variant_name(i);
+ quote! { #variant([Value; #i]) }
+ });
+ let physical_values_first_value = (1..=count).map(|i| {
+ let variant = variant_name(i);
+ quote! {
+ Self::#variant(values) => {
+ if let Some(value) = values.first() {
+ *value
+ } else {
+ // NOTE(no-panic): there is always at least one value
+ unreachable!();
+ }
+ }
+ }
+ });
+
+ let reading_axes_variants = (1..=count).map(|i| {
+ let variant = variant_name(i);
+ quote! { #variant([ReadingAxis; #i]) }
+ });
+
+ let values_iter = (1..=count)
+ .map(|i| {
+ let variant = variant_name(i);
+ quote! { Self::#variant(values) => values.iter().copied() }
+ })
+ .collect::>();
+
+ let expanded = quote! {
+ /// Values returned by a sensor driver.
+ ///
+ /// This type implements [`Reading`] to iterate over the values.
+ ///
+ /// # Note
+ ///
+ /// This type is automatically generated, the number of variants is automatically adjusted.
+ #[derive(Debug, Copy, Clone)]
+ pub enum Values {
+ #[doc(hidden)]
+ #(#physical_values_variants),*
+ }
+
+ impl Reading for Values {
+ fn value(&self) -> Value {
+ match self {
+ #(#physical_values_first_value),*
+ }
+ }
+
+ fn values(&self) -> impl ExactSizeIterator- {
+ match self {
+ #(#values_iter),*
+ }
+ }
+ }
+
+ /// Metadata required to interpret values returned by [`Sensor::wait_for_reading()`].
+ ///
+ /// # Note
+ ///
+ /// This type is automatically generated, the number of variants is automatically adjusted.
+ #[derive(Debug, Copy, Clone)]
+ pub enum ReadingAxes {
+ #[doc(hidden)]
+ #(#reading_axes_variants),*,
+ }
+
+ impl ReadingAxes {
+ /// Returns an iterator over the underlying [`ReadingAxis`] items.
+ ///
+ /// For a given sensor driver, the number and order of items match the one of
+ /// [`Values`].
+ /// [`Iterator::zip()`] can be useful to zip the returned iterator with the one
+ /// obtained with [`Reading::values()`].
+ pub fn iter(&self) -> impl Iterator
- + '_ {
+ match self {
+ #(#values_iter),*,
+ }
+ }
+
+ /// Returns the first [`ReadingAxis`].
+ pub fn first(&self) -> ReadingAxis {
+ if let Some(value) = self.iter().next() {
+ value
+ } else {
+ // NOTE(no-panic): there is always at least one value.
+ unreachable!();
+ }
+ }
+ }
+ };
+
+ TokenStream::from(expanded)
+}
+
+mod define_count_adjusted_enum {
+ pub fn variant_name(index: usize) -> syn::Ident {
+ quote::format_ident!("V{index}")
+ }
+}
diff --git a/src/riot-rs-macros/src/lib.rs b/src/riot-rs-macros/src/lib.rs
index d1a72394c..5c63aa236 100644
--- a/src/riot-rs-macros/src/lib.rs
+++ b/src/riot-rs-macros/src/lib.rs
@@ -5,6 +5,7 @@ mod utils;
use proc_macro::TokenStream;
include!("config.rs");
+include!("define_count_adjusted_sensor_enums.rs");
include!("spawner.rs");
include!("task.rs");
include!("thread.rs");
diff --git a/src/riot-rs-rt/linkme.x b/src/riot-rs-rt/linkme.x
index 9a22004c0..099d975e0 100644
--- a/src/riot-rs-rt/linkme.x
+++ b/src/riot-rs-rt/linkme.x
@@ -5,6 +5,8 @@ SECTIONS {
linkm2_EMBASSY_TASKS : { *(linkm2_EMBASSY_TASKS) } > FLASH
linkme_USB_BUILDER_HOOKS : { *(linkme_USB_BUILDER_HOOKS) } > FLASH
linkm2_USB_BUILDER_HOOKS : { *(linkm2_USB_BUILDER_HOOKS) } > FLASH
+ linkme_SENSOR_REFS : { *(linkme_SENSOR_REFS) } > FLASH
+ linkm2_SENSOR_REFS : { *(linkm2_SENSOR_REFS) } > FLASH
linkme_THREAD_FNS : { *(linkme_THREAD_FNS) } > FLASH
linkm2_THREAD_FNS : { *(linkm2_THREAD_FNS) } > FLASH
}
diff --git a/src/riot-rs-sensors/Cargo.toml b/src/riot-rs-sensors/Cargo.toml
new file mode 100644
index 000000000..41f266f5d
--- /dev/null
+++ b/src/riot-rs-sensors/Cargo.toml
@@ -0,0 +1,31 @@
+[package]
+name = "riot-rs-sensors"
+version.workspace = true
+authors.workspace = true
+license.workspace = true
+edition.workspace = true
+repository.workspace = true
+
+[lints]
+workspace = true
+
+[dependencies]
+defmt = { workspace = true, optional = true }
+embassy-sync = { workspace = true }
+linkme = { workspace = true }
+pin-project = { workspace = true }
+riot-rs-macros = { workspace = true }
+
+[features]
+defmt = ["dep:defmt"]
+
+# These features could be codegened
+max-reading-value-min-count-2 = ["riot-rs-macros/max-reading-value-min-count-2"]
+max-reading-value-min-count-3 = ["riot-rs-macros/max-reading-value-min-count-3"]
+max-reading-value-min-count-4 = ["riot-rs-macros/max-reading-value-min-count-4"]
+max-reading-value-min-count-6 = ["riot-rs-macros/max-reading-value-min-count-6"]
+max-reading-value-min-count-7 = ["riot-rs-macros/max-reading-value-min-count-7"]
+max-reading-value-min-count-9 = ["riot-rs-macros/max-reading-value-min-count-9"]
+max-reading-value-min-count-12 = [
+ "riot-rs-macros/max-reading-value-min-count-12",
+]
diff --git a/src/riot-rs-sensors/src/category.rs b/src/riot-rs-sensors/src/category.rs
new file mode 100644
index 000000000..309ca41d1
--- /dev/null
+++ b/src/riot-rs-sensors/src/category.rs
@@ -0,0 +1,54 @@
+/// Categories a sensor driver can be part of.
+///
+/// A sensor driver can be part of multiple categories.
+///
+/// # For sensor driver implementors
+///
+/// Many mechanical sensor devices (e.g., accelerometers) include a temperature sensor as
+/// temperature may slightly affect the measurement results.
+/// If temperature readings are not exposed by the sensor driver, the sensor driver must not be
+/// considered part of a category that includes temperature ([`Category::Temperature`] or
+/// [`Category::AccelerometerTemperature`] in the case of an accelerometer).
+///
+/// Missing variants can be added when required.
+/// Please open an issue to discuss it.
+// Built upon https://doc.riot-os.org/group__drivers__saul.html#ga8f2dfec7e99562dbe5d785467bb71bbb
+#[derive(Debug, Copy, Clone, PartialEq, Eq)]
+#[cfg_attr(feature = "defmt", derive(defmt::Format))]
+#[non_exhaustive]
+pub enum Category {
+ /// Accelerometer.
+ Accelerometer,
+ /// Accelerometer & temperature sensor.
+ AccelerometerTemperature,
+ /// Accelerometer & magnetometer & temperature sensor.
+ AccelerometerMagnetometerTemperature,
+ /// Ammeter (ampere meter).
+ Ammeter,
+ /// CO₂ gas sensor.
+ Co2Gas,
+ /// Color sensor.
+ Color,
+ /// Gyroscope.
+ Gyroscope,
+ /// Humidity sensor.
+ Humidity,
+ /// Humidity & temperature sensor.
+ HumidityTemperature,
+ /// Light sensor.
+ Light,
+ /// Magnetometer.
+ Magnetometer,
+ /// pH sensor.
+ Ph,
+ /// Pressure sensor.
+ Pressure,
+ /// Push button.
+ PushButton,
+ /// Temperature sensor.
+ Temperature,
+ /// TVOC sensor.
+ Tvoc,
+ /// Voltage sensor.
+ Voltage,
+}
diff --git a/src/riot-rs-sensors/src/label.rs b/src/riot-rs-sensors/src/label.rs
new file mode 100644
index 000000000..f09c340d2
--- /dev/null
+++ b/src/riot-rs-sensors/src/label.rs
@@ -0,0 +1,44 @@
+/// Label of a [`Value`](crate::sensor::Value) part of a
+/// [`Values`](crate::sensor::Values) tuple.
+///
+/// # For sensor driver implementors
+///
+/// Missing variants can be added when required.
+/// Please open an issue to discuss it.
+///
+/// [`Label::Main`] must be used for sensor drivers returning a single
+/// [`Value`](crate::sensor::Value), even if a more specific label exists for the
+/// physical quantity.
+/// This allows consumers displaying the label to ignore it for sensor drivers returning a single
+/// [`Value`](crate::sensor::Value).
+/// Other labels are reserved for sensor drivers returning multiple physical quantities.
+#[derive(Debug, Copy, Clone, PartialEq, Eq)]
+#[cfg_attr(feature = "defmt", derive(defmt::Format))]
+#[non_exhaustive]
+pub enum Label {
+ /// Used for sensor drivers returning a single [`Value`](crate::sensor::Value).
+ Main,
+ /// Humidity.
+ Humidity,
+ /// Temperature.
+ Temperature,
+ /// X axis.
+ X,
+ /// Y axis.
+ Y,
+ /// Z axis.
+ Z,
+}
+
+impl core::fmt::Display for Label {
+ fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+ match self {
+ Self::Main => write!(f, ""),
+ Self::Humidity => write!(f, "Humidity"),
+ Self::Temperature => write!(f, "Temperature"),
+ Self::X => write!(f, "X"),
+ Self::Y => write!(f, "Y"),
+ Self::Z => write!(f, "Z"),
+ }
+ }
+}
diff --git a/src/riot-rs-sensors/src/lib.rs b/src/riot-rs-sensors/src/lib.rs
new file mode 100644
index 000000000..cd1530f21
--- /dev/null
+++ b/src/riot-rs-sensors/src/lib.rs
@@ -0,0 +1,67 @@
+//! Provides a sensor abstraction layer.
+//!
+//! # Definitions
+//!
+//! In the context of this abstraction:
+//!
+//! - A *sensor device* is a device measuring one or multiple physical quantities and reporting
+//! them as one or more digital values. - Sensor devices measuring the same physical quantity are
+//! said to be part of the same *sensor category*.
+//! A sensor device may be part of multiple sensor categories.
+//! - A *measurement* is the physical operation of measuring one or several physical quantities.
+//! - A *reading* is the digital result returned by a sensor device after carrying out a
+//! measurement.
+//! Values of different physical quantities can therefore be part of the same reading.
+//! - A *sensor driver* refers to a sensor device as exposed by the sensor abstraction layer.
+//! - A *sensor driver instance* is an instance of a sensor driver.
+//!
+//! # Accessing sensor driver instances
+//!
+//! Registered sensor driver instances can be accessed using
+//! [`REGISTRY::sensors()`](registry::Registry::sensors).
+//! Sensor drivers implement the [`Sensor`] trait, which allows to trigger measurements and obtain
+//! the resulting readings.
+//!
+//! # Obtaining a sensor reading
+//!
+//! After triggering a measurement with [`Sensor::trigger_measurement()`], a reading can be
+//! obtained using [`Sensor::wait_for_reading()`].
+//! It is additionally necessary to use [`Sensor::reading_axes()`] to make sense of the obtained
+//! reading:
+//!
+//! - [`Sensor::wait_for_reading()`] returns a [`Values`](sensor::Values), a data "tuple"
+//! containing values returned by the sensor driver.
+//! - [`Sensor::reading_axes()`] returns a [`ReadingAxes`](sensor::ReadingAxes) which
+//! indicates which physical quantity each [`Value`](value::Value) from that tuple corresponds
+//! to, using a [`Label`].
+//! For instance, this allows to disambiguate the values provided by a temperature & humidity
+//! sensor.
+//!
+//! To avoid handling floats, [`Value`](value::Value)s returned by [`Sensor::wait_for_reading()`]
+//! are integers, and a fixed scaling value is provided in [`ReadingAxis`](sensor::ReadingAxis),
+//! for each [`Value`](value::Value) returned.
+//! See [`Value`](value::Value) for more details.
+//!
+//! # For implementors
+//!
+//! Sensor drivers must implement the [`Sensor`] trait.
+//!
+#![no_std]
+// Required by linkme
+#![feature(used_with_arg)]
+#![deny(clippy::pedantic)]
+#![deny(missing_docs)]
+
+mod category;
+mod label;
+mod measurement_unit;
+pub mod registry;
+pub mod sensor;
+mod value;
+
+pub use category::Category;
+pub use label::Label;
+pub use measurement_unit::MeasurementUnit;
+pub use registry::{REGISTRY, SENSOR_REFS};
+pub use sensor::Sensor;
+pub use value::Reading;
diff --git a/src/riot-rs-sensors/src/measurement_unit.rs b/src/riot-rs-sensors/src/measurement_unit.rs
new file mode 100644
index 000000000..201c29c76
--- /dev/null
+++ b/src/riot-rs-sensors/src/measurement_unit.rs
@@ -0,0 +1,125 @@
+/// Represents a unit of measurement.
+///
+/// # For sensor driver implementors
+///
+/// Missing variants can be added when required.
+/// Please open an issue to discuss it.
+// Built upon https://doc.riot-os.org/phydat_8h_source.html
+// and https://bthome.io/format/#sensor-data
+// and https://www.iana.org/assignments/senml/senml.xhtml
+#[derive(Debug, Copy, Clone, PartialEq, Eq)]
+#[cfg_attr(feature = "defmt", derive(defmt::Format))]
+#[non_exhaustive]
+pub enum MeasurementUnit {
+ /// [Acceleration *g*](https://en.wikipedia.org/wiki/G-force#Unit_and_measurement).
+ AccelG,
+ /// Ampere (A).
+ Ampere,
+ /// Becquerel (Bq).
+ Becquerel,
+ /// Logic boolean: `0` means `false` and `1` means `true`.
+ Bool,
+ /// Candela (cd).
+ Candela,
+ /// Degrees Celsius (°C).
+ Celsius,
+ /// Coulomb (C).
+ Coulomb,
+ /// Decibel (dB).
+ Decibel,
+ /// Farad (F).
+ Farad,
+ // FIXME: Kilogram as well?
+ /// Gram (g).
+ Gram,
+ /// Gray (Gy).
+ Gray,
+ /// Henry (H).
+ Henry,
+ /// Hertz (Hz).
+ Hertz,
+ /// Joule (J).
+ Joule,
+ /// Katal (kat).
+ Katal,
+ /// Kelvin (K).
+ Kelvin,
+ /// Lumen (lm).
+ Lumen,
+ /// Lux (lx).
+ Lux,
+ /// Meter (m)
+ Meter,
+ /// Mole (mol).
+ Mole,
+ /// Newton (N).
+ Newton,
+ /// Ohm (Ω).
+ Ohm,
+ /// Pascal (Pa).
+ Pascal,
+ /// Percent (%).
+ Percent,
+ /// %RH.
+ PercentageRelativeHumidity,
+ /// Radian (rad).
+ Radian,
+ /// Second (s).
+ Second,
+ /// Siemens (S).
+ Siemens,
+ /// Sievert (Sv).
+ Sievert,
+ /// Steradian (sr).
+ Steradian,
+ /// Tesla (T).
+ Tesla,
+ /// Volt (V).
+ Volt,
+ /// Watt (W).
+ Watt,
+ /// Weber (Wb).
+ Weber,
+}
+
+impl core::fmt::Display for MeasurementUnit {
+ fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+ #[expect(clippy::match_same_arms)]
+ match self {
+ Self::AccelG => write!(f, "g"),
+ Self::Ampere => write!(f, "A"),
+ Self::Becquerel => write!(f, "Bq"),
+ Self::Bool => write!(f, ""),
+ Self::Candela => write!(f, "cd"),
+ Self::Celsius => write!(f, "°C"), // The Unicode Standard v15 recommends using U+00B0 + U+0043.
+ Self::Coulomb => write!(f, "C"),
+ Self::Decibel => write!(f, "dB"),
+ Self::Farad => write!(f, "F"),
+ Self::Gram => write!(f, "g"),
+ Self::Gray => write!(f, "Gy"),
+ Self::Henry => write!(f, "H"),
+ Self::Hertz => write!(f, "Hz"),
+ Self::Joule => write!(f, "J"),
+ Self::Katal => write!(f, "kat"),
+ Self::Kelvin => write!(f, "K"),
+ Self::Lumen => write!(f, "lm"),
+ Self::Lux => write!(f, "lx"),
+ Self::Meter => write!(f, "m"),
+ Self::Mole => write!(f, "mol"),
+ Self::Newton => write!(f, "N"),
+ Self::Ohm => write!(f, "Ω"),
+ Self::Pascal => write!(f, "Pa"),
+ Self::Percent => write!(f, "%"),
+ Self::PercentageRelativeHumidity => write!(f, "%RH"),
+ Self::Radian => write!(f, "rad"),
+ Self::Second => write!(f, "s"),
+ Self::Siemens => write!(f, "S"),
+ Self::Sievert => write!(f, "Sv"),
+ Self::Steradian => write!(f, "sr"),
+ Self::Tesla => write!(f, "T"),
+ Self::Volt => write!(f, "V"),
+ Self::Watt => write!(f, "W"),
+ Self::Weber => write!(f, "Wb"),
+ }
+ }
+}
diff --git a/src/riot-rs-sensors/src/registry.rs b/src/riot-rs-sensors/src/registry.rs
new file mode 100644
index 000000000..86732ed0e
--- /dev/null
+++ b/src/riot-rs-sensors/src/registry.rs
@@ -0,0 +1,42 @@
+//! Provides a sensor driver instance registry, allowing to register sensor driver instances and
+//! access them in a centralized location.
+
+use crate::Sensor;
+
+/// Stores references to registered sensor driver instances.
+///
+/// To register a sensor driver instance, insert a `&'static` into this [distributed
+/// slice](linkme).
+/// The sensor driver will therefore need to be statically allocated, to be able to obtain a
+/// `&'static`.
+// Exclude this from the users' documentation, to force users to use `Registry::sensors()` instead,
+// for easier forward compatibility with possibly non-static references.
+#[doc(hidden)]
+#[linkme::distributed_slice]
+pub static SENSOR_REFS: [&'static dyn Sensor] = [..];
+
+/// The global registry instance.
+pub static REGISTRY: Registry = Registry::new();
+
+/// The sensor driver instance registry.
+///
+/// This is exposed as [`REGISTRY`].
+pub struct Registry {
+ // Prevents instantiation from outside this module.
+ _private: (),
+}
+
+impl Registry {
+ // The constructor is private to make the registry a singleton.
+ const fn new() -> Self {
+ Self { _private: () }
+ }
+
+ /// Returns an iterator over registered sensor driver instances.
+ pub fn sensors(&self) -> impl Iterator
- {
+ // Returning an iterator instead of the distributed slice directly would allow us to chain
+ // another source of sensor driver instances in the future, if we decided to support
+ // dynamically-allocated sensor driver instances.
+ SENSOR_REFS.iter().copied()
+ }
+}
diff --git a/src/riot-rs-sensors/src/sensor.rs b/src/riot-rs-sensors/src/sensor.rs
new file mode 100644
index 000000000..f344d076d
--- /dev/null
+++ b/src/riot-rs-sensors/src/sensor.rs
@@ -0,0 +1,326 @@
+//! Provides a [`Sensor`] trait abstracting over implementation details of a sensor driver.
+use core::{
+ future::Future,
+ pin::Pin,
+ task::{Context, Poll},
+};
+
+use embassy_sync::{blocking_mutex::raw::CriticalSectionRawMutex, channel::ReceiveFuture};
+
+use crate::{Category, Label, MeasurementUnit};
+
+pub use crate::{
+ value::{Accuracy, Value},
+ Reading,
+};
+
+riot_rs_macros::define_count_adjusted_sensor_enums!();
+
+/// This trait must be implemented by sensor drivers.
+///
+/// See [the module level documentation](crate) for more.
+pub trait Sensor: Send + Sync {
+ /// Triggers a measurement.
+ /// Clears the previous reading.
+ ///
+ /// To obtain readings from every sensor drivers this method can be called in a loop over all
+ /// sensors returned by [`Registry::sensors()`](crate::registry::Registry::sensors), before
+ /// obtaining the readings with [`Self::wait_for_reading()`] in a second loop, so that the
+ /// measurements happen concurrently.
+ ///
+ /// # For implementors
+ ///
+ /// This method should return quickly.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`TriggerMeasurementError::NonEnabled`] if the sensor driver is not enabled.
+ fn trigger_measurement(&self) -> Result<(), TriggerMeasurementError>;
+
+ /// Waits for the reading and returns it asynchronously.
+ /// Depending on the sensor device and the sensor driver, this may use a sensor interrupt or
+ /// data polling.
+ /// Interpretation of the reading requires data from [`Sensor::reading_axes()`] as well.
+ /// See [the module level documentation](crate) for more.
+ ///
+ /// # Note
+ ///
+ /// It is necessary to trigger a measurement by calling [`Sensor::trigger_measurement()`]
+ /// beforehand, even if the sensor device carries out periodic measurements on its own.
+ ///
+ /// # Errors
+ ///
+ /// - Quickly returns [`ReadingError::NonEnabled`] if the sensor driver is not enabled.
+ /// - Quickly returns [`ReadingError::NotMeasuring`] if no measurement has been triggered
+ /// beforehand using [`Sensor::trigger_measurement()`].
+ /// - Returns [`ReadingError::SensorAccess`] if the sensor device cannot be accessed.
+ fn wait_for_reading(&'static self) -> ReadingWaiter;
+
+ /// Provides information about the reading returned by [`Sensor::wait_for_reading()`].
+ #[must_use]
+ fn reading_axes(&self) -> ReadingAxes;
+
+ /// Sets the sensor driver mode and returns the previous state.
+ /// Allows to put the sensor device to sleep if supported.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`SetModeError::Uninitialized`] if the sensor driver is not initialized.
+ fn set_mode(&self, mode: Mode) -> Result;
+
+ /// Returns the current sensor driver state.
+ #[must_use]
+ fn state(&self) -> State;
+
+ /// Returns the categories the sensor device is part of.
+ #[must_use]
+ fn categories(&self) -> &'static [Category];
+
+ /// String label of the sensor driver *instance*.
+ /// For instance, in the case of a temperature sensor, this allows to specify whether this
+ /// specific sensor device is placed indoor or outdoor.
+ #[must_use]
+ fn label(&self) -> Option<&'static str>;
+
+ /// Returns a human-readable name of the *sensor driver*.
+ /// For instance, "push button" and "3-axis accelerometer" are appropriate display names.
+ ///
+ /// # Note
+ ///
+ /// Different sensor drivers for the same sensor device may have different display names.
+ #[must_use]
+ fn display_name(&self) -> Option<&'static str>;
+
+ /// Returns the sensor device part number.
+ /// Returns `None` when the sensor device does not have a part number.
+ /// For instance, "DS18B20" is a valid part number.
+ #[must_use]
+ fn part_number(&self) -> Option<&'static str>;
+
+ /// Returns the sensor driver version number.
+ #[must_use]
+ fn version(&self) -> u8;
+}
+
+/// Future returned by [`Sensor::wait_for_reading()`].
+#[must_use = "futures do nothing unless you `.await` or poll them"]
+#[pin_project::pin_project(project = ReadingWaiterProj)]
+pub enum ReadingWaiter {
+ #[doc(hidden)]
+ Waiter {
+ #[pin]
+ waiter: ReceiveFuture<'static, CriticalSectionRawMutex, ReadingResult, 1>,
+ },
+ #[doc(hidden)]
+ Err(ReadingError),
+ #[doc(hidden)]
+ Resolved,
+}
+
+impl Future for ReadingWaiter {
+ type Output = ReadingResult;
+
+ fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll {
+ let this = self.as_mut().project();
+ match this {
+ ReadingWaiterProj::Waiter { waiter } => waiter.poll(cx),
+ ReadingWaiterProj::Err(err) => {
+ // Replace the error with a dummy error value, crafted from thin air, and mark the
+ // future as resolved, so that we do not take this dummy value into account later.
+ // This avoids requiring `Clone` on `ReadingError`.
+ let err = core::mem::replace(err, ReadingError::NonEnabled);
+ *self = ReadingWaiter::Resolved;
+
+ Poll::Ready(Err(err))
+ }
+ ReadingWaiterProj::Resolved => unreachable!(),
+ }
+ }
+}
+
+/// Mode of a sensor driver.
+#[derive(Debug, Copy, Clone, PartialEq, Eq)]
+#[cfg_attr(feature = "defmt", derive(defmt::Format))]
+pub enum Mode {
+ /// The sensor driver is disabled.
+ Disabled,
+ /// The sensor driver is enabled.
+ Enabled,
+ /// The sensor driver is sleeping.
+ /// The sensor device may be in a low-power mode.
+ Sleeping,
+}
+
+/// Possible errors when attempting to set the mode of a sensor driver.
+#[derive(Debug)]
+#[cfg_attr(feature = "defmt", derive(defmt::Format))]
+pub enum SetModeError {
+ /// The sensor driver is uninitialized.
+ /// It has not been initialized yet, or initialization could not succeed.
+ Uninitialized,
+}
+
+impl core::fmt::Display for SetModeError {
+ fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+ match self {
+ Self::Uninitialized => write!(f, "sensor driver is not initialized"),
+ }
+ }
+}
+
+impl core::error::Error for SetModeError {}
+
+/// State of a sensor driver.
+#[derive(Debug, Copy, Clone, Default, PartialEq, Eq)]
+#[cfg_attr(feature = "defmt", derive(defmt::Format))]
+#[repr(u8)]
+pub enum State {
+ /// The sensor driver is uninitialized.
+ /// It has not been initialized yet, or initialization could not succeed.
+ #[default]
+ Uninitialized = 0,
+ /// The sensor driver is disabled.
+ Disabled = 1,
+ /// The sensor driver is enabled.
+ Enabled = 2,
+ /// The sensor driver is enabled and a measurement has been triggered.
+ Measuring = 3,
+ /// The sensor driver is sleeping.
+ Sleeping = 4,
+}
+
+impl From for State {
+ fn from(mode: Mode) -> Self {
+ match mode {
+ Mode::Disabled => Self::Disabled,
+ Mode::Enabled => Self::Enabled,
+ Mode::Sleeping => Self::Sleeping,
+ }
+ }
+}
+
+impl TryFrom for State {
+ type Error = TryFromIntError;
+
+ fn try_from(int: u8) -> Result {
+ match int {
+ 0 => Ok(Self::Uninitialized),
+ 1 => Ok(Self::Disabled),
+ 2 => Ok(Self::Enabled),
+ 3 => Ok(Self::Measuring),
+ 4 => Ok(Self::Sleeping),
+ _ => Err(TryFromIntError),
+ }
+ }
+}
+
+/// The error type returned when a checked integral type conversion fails.
+#[derive(Debug)]
+#[cfg_attr(feature = "defmt", derive(defmt::Format))]
+pub struct TryFromIntError;
+
+impl core::fmt::Display for TryFromIntError {
+ fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+ write!(f, "out of range integral type conversion attempted")
+ }
+}
+
+impl core::error::Error for TryFromIntError {}
+
+/// Provides metadata about a [`Value`].
+#[derive(Debug, Copy, Clone, PartialEq)]
+#[cfg_attr(feature = "defmt", derive(defmt::Format))]
+// NOTE(derive): we do not implement `Eq` on purpose: its would prevent us from possibly adding
+// floats in the future.
+pub struct ReadingAxis {
+ label: Label,
+ scaling: i8,
+ unit: MeasurementUnit,
+}
+
+impl ReadingAxis {
+ /// Creates a new [`ReadingAxis`].
+ ///
+ /// This constructor is intended for sensor driver implementors only.
+ #[must_use]
+ pub fn new(label: Label, scaling: i8, unit: MeasurementUnit) -> Self {
+ Self {
+ label,
+ scaling,
+ unit,
+ }
+ }
+
+ /// Returns the [`Label`] for this axis.
+ #[must_use]
+ pub fn label(&self) -> Label {
+ self.label
+ }
+
+ /// Returns the [scaling](Value) for this axis.
+ #[must_use]
+ pub fn scaling(&self) -> i8 {
+ self.scaling
+ }
+
+ /// Returns the unit of measurement for this axis.
+ #[must_use]
+ pub fn unit(&self) -> MeasurementUnit {
+ self.unit
+ }
+}
+
+/// Represents errors happening when *triggering* a sensor measurement.
+#[derive(Debug)]
+#[cfg_attr(feature = "defmt", derive(defmt::Format))]
+pub enum TriggerMeasurementError {
+ /// The sensor driver is not enabled (e.g., it may be disabled or sleeping).
+ NonEnabled,
+}
+
+impl core::fmt::Display for TriggerMeasurementError {
+ fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+ match self {
+ Self::NonEnabled => write!(f, "sensor driver is not enabled"),
+ }
+ }
+}
+
+impl core::error::Error for TriggerMeasurementError {}
+
+/// Represents errors happening when accessing a sensor reading.
+#[derive(Debug)]
+#[cfg_attr(feature = "defmt", derive(defmt::Format))]
+pub enum ReadingError {
+ /// The sensor driver is not enabled (e.g., it may be disabled or sleeping).
+ NonEnabled,
+ /// Cannot access the sensor device (e.g., because of a bus error).
+ SensorAccess,
+ /// No measurement has been triggered before waiting for a reading.
+ /// It is necessary to call [`Sensor::trigger_measurement()`] before calling
+ /// [`Sensor::wait_for_reading()`].
+ NotMeasuring,
+}
+
+impl core::fmt::Display for ReadingError {
+ fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+ match self {
+ Self::NonEnabled => write!(f, "sensor driver is not enabled"),
+ Self::SensorAccess => write!(f, "sensor device could not be accessed"),
+ Self::NotMeasuring => write!(f, "no measurement has been triggered"),
+ }
+ }
+}
+
+impl core::error::Error for ReadingError {}
+
+/// A specialized [`Result`] type for [`Reading`] operations.
+pub type ReadingResult = Result;
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ // Assert that the Sensor trait is object-safe
+ static _SENSOR_REFS: &[&dyn Sensor] = &[];
+}
diff --git a/src/riot-rs-sensors/src/value.rs b/src/riot-rs-sensors/src/value.rs
new file mode 100644
index 000000000..cc0ca2cb1
--- /dev/null
+++ b/src/riot-rs-sensors/src/value.rs
@@ -0,0 +1,130 @@
+#[expect(clippy::doc_markdown)]
+/// Represents a value obtained from a sensor device, along with its accuracy.
+///
+/// # Scaling
+///
+/// The [scaling value](crate::sensor::ReadingAxis::scaling()) obtained from the sensor driver with
+/// [`Sensor::reading_axes()`](crate::Sensor::reading_axes) must be taken into account using the
+/// following formula:
+///
+///
+///
+/// For instance, in the case of a temperature sensor, if [`Self::value()`] returns `2225` and the
+/// scaling value is `-2`, this means that the temperature measured and returned by the sensor
+/// device is `22.25` (the [measurement error](Accuracy) must additionally be taken into
+/// account).
+/// This is required to avoid handling floats.
+///
+/// # Unit of measurement
+///
+/// The unit of measurement can be obtained using
+/// [`ReadingAxis::unit()`](crate::sensor::ReadingAxis::unit).
+///
+/// # Accuracy
+///
+/// The accuracy can be obtained with [`Self::accuracy()`].
+// NOTE(derive): we do not implement `Eq` or `PartialOrd` on purpose: `Eq` would prevent us from
+// possibly adding floats in the future and `PartialOrd` does not make sense because interpreting
+// the value requires the `ReadingAxis` associated with this `Value`.
+#[derive(Debug, Copy, Clone, PartialEq)]
+#[cfg_attr(feature = "defmt", derive(defmt::Format))]
+pub struct Value {
+ value: i32,
+ accuracy: Accuracy,
+}
+
+impl Value {
+ /// Creates a new value.
+ ///
+ /// This constructor is intended for sensor driver implementors only.
+ #[must_use]
+ pub const fn new(value: i32, accuracy: Accuracy) -> Self {
+ Self { value, accuracy }
+ }
+
+ /// Returns the value.
+ #[must_use]
+ pub fn value(&self) -> i32 {
+ self.value
+ }
+
+ /// Returns the measurement accuracy.
+ #[must_use]
+ pub fn accuracy(&self) -> Accuracy {
+ self.accuracy
+ }
+}
+
+/// Specifies the accuracy of a measurement.
+#[derive(Debug, Copy, Clone, PartialEq)]
+#[cfg_attr(feature = "defmt", derive(defmt::Format))]
+pub enum Accuracy {
+ /// Unknown accuracy.
+ Unknown,
+ /// No measurement error (e.g., boolean values from a push button).
+ NoError,
+ /// Measurement error symmetrical around the [`bias`](Accuracy::SymmetricalError::bias).
+ ///
+ /// The unit of measurement is provided by the [`ReadingAxis`](crate::sensor::ReadingAxis)
+ /// associated to the [`Value`].
+ /// The `scaling` value is used for both `deviation` and `bias`.
+ /// The accuracy error is thus given by the following formulas:
+ ///
+ ///
+ ///
+ /// # Examples
+ ///
+ /// The DS18B20 temperature sensor accuracy error is +0.05/-0.45
+ /// at 20 °C (see Figure 1 of its datasheet).
+ /// [`Accuracy`] would thus be the following:
+ ///
+ /// ```
+ /// # use riot_rs_sensors::sensor::Accuracy;
+ /// Accuracy::SymmetricalError {
+ /// deviation: 25,
+ /// bias: -20,
+ /// scaling: -2,
+ /// }
+ /// # ;
+ /// ```
+ SymmetricalError {
+ /// Deviation around the bias value.
+ deviation: i8,
+ /// Bias (mean accuracy error).
+ bias: i8,
+ /// Scaling of [`deviation`](Accuracy::SymmetricalError::deviation) and
+ /// [`bias`](Accuracy::SymmetricalError::bias).
+ scaling: i8,
+ },
+}
+
+/// Implemented on [`Values`](crate::sensor::Values), returned by
+/// [`Sensor::wait_for_reading()`](crate::Sensor::wait_for_reading).
+pub trait Reading: core::fmt::Debug {
+ /// Returns the first value returned by [`Reading::values()`].
+ fn value(&self) -> Value;
+
+ /// Returns an iterator over [`Value`]s of a sensor reading.
+ ///
+ /// The order of [`Value`]s is not significant, but is fixed.
+ ///
+ /// # For implementors
+ ///
+ /// The default implementation must be overridden on types containing multiple
+ /// [`Value`]s.
+ fn values(&self) -> impl ExactSizeIterator
- {
+ [self.value()].into_iter()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn assert_type_sizes() {
+ assert!(size_of::() <= size_of::());
+ // Make sure the type is small enough.
+ assert!(size_of::() <= 2 * size_of::());
+ }
+}
diff --git a/src/riot-rs/Cargo.toml b/src/riot-rs/Cargo.toml
index da6ffc527..621ad042f 100644
--- a/src/riot-rs/Cargo.toml
+++ b/src/riot-rs/Cargo.toml
@@ -18,6 +18,7 @@ riot-rs-embassy = { path = "../riot-rs-embassy" }
riot-rs-macros = { path = "../riot-rs-macros" }
riot-rs-random = { workspace = true, optional = true }
riot-rs-rt = { path = "../riot-rs-rt" }
+riot-rs-sensors = { workspace = true, optional = true }
riot-rs-threads = { path = "../riot-rs-threads", optional = true }
riot-rs-utils = { workspace = true }
static_cell = { workspace = true }
@@ -43,6 +44,9 @@ random = ["riot-rs-random"]
csprng = ["riot-rs-random/csprng"]
## Enables seeding the random number generator from hardware.
hwrng = ["riot-rs-embassy/hwrng"]
+## Enables support for sensors.
+## *Currently experimental.*
+sensors = ["dep:riot-rs-sensors"]
#! ## Serial communication
## Enables I2C support.
diff --git a/src/riot-rs/src/lib.rs b/src/riot-rs/src/lib.rs
index e88a610cf..e21fcc42a 100644
--- a/src/riot-rs/src/lib.rs
+++ b/src/riot-rs/src/lib.rs
@@ -23,6 +23,9 @@ pub use riot_rs_debug as debug;
pub use riot_rs_random as random;
#[doc(inline)]
pub use riot_rs_rt as rt;
+#[cfg(feature = "sensors")]
+#[doc(inline)]
+pub use riot_rs_sensors as sensors;
#[cfg(feature = "threading")]
#[doc(inline)]
pub use riot_rs_threads as thread;