initial commit

This commit is contained in:
Sebastian Hugentobler 2024-11-17 10:27:30 +01:00
commit 17a8e573c0
Signed by: shu
GPG Key ID: BB32CF3CA052C2F0
40 changed files with 4009 additions and 0 deletions

1
.envrc Normal file
View File

@ -0,0 +1 @@
use flake

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
target/
/.direnv

11
Cargo.lock generated Normal file
View File

@ -0,0 +1,11 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "mqtt-protocol"
version = "0.1.0"
[[package]]
name = "serial-comm"
version = "0.1.0"

12
Cargo.toml Normal file
View File

@ -0,0 +1,12 @@
[workspace]
members = ["serial-comm", "mqtt-protocol"]
resolver = "2"
[workspace.package]
version = "0.1.0"
edition = "2021"
description = "rp2040 temperature sensor"
license = "GPL-3.0-or-later"
[profile.release]
strip = true

View File

@ -0,0 +1,8 @@
[target.'cfg(all(target_arch = "arm", target_os = "none"))']
runner = "probe-rs run --chip RP2040"
[build]
target = "thumbv6m-none-eabi" # Cortex-M0 and Cortex-M0+
[env]
DEFMT_LOG = "info"

1973
controller/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

68
controller/Cargo.toml Normal file
View File

@ -0,0 +1,68 @@
[workspace]
[package]
edition = "2021"
name = "controller"
version = "0.1.0"
[dependencies]
embassy-embedded-hal = { version = "0.2.0",features = ["defmt"] }
embassy-sync = { version = "0.6.0", features = ["defmt"] }
embassy-executor = { version = "0.6.3", features = [
"arch-cortex-m",
"task-arena-size-98304",
"executor-thread",
"executor-interrupt",
"defmt",
"integrated-timers",
] }
embassy-time = { version = "0.3.2", features = ["defmt", "defmt-timestamp-uptime"] }
embassy-rp = { version = "0.2.0", features = [
"defmt",
"unstable-pac",
"time-driver",
"critical-section-impl",
] }
embassy-usb = { version = "0.3.0", features = ["defmt"] }
embassy-net = { version = "0.4.0", features = [
"defmt",
"tcp",
"udp",
"dhcpv4",
"medium-ethernet",
] }
embassy-net-wiznet = { version = "0.1.0", features = ["defmt"] }
embassy-futures = "0.1.1"
cyw43 = { version = "0.2.0", features = ["defmt", "firmware-logs"] }
cyw43-pio = { version = "0.2.0", features = ["defmt", "overclock"] }
defmt = "0.3.8"
defmt-rtt = "0.4.1"
fixed = "1.28.0"
fixed-macro = "1.2.0"
cortex-m = { version = "0.7.7", features = ["inline-asm"] }
cortex-m-rt = "0.7.5"
panic-probe = { version = "0.3.2", features = ["print-defmt"] }
futures = { version = "0.3.31", default-features = false, features = [
"async-await",
"cfg-target-has-atomic",
] }
critical-section = "1.2.0"
portable-atomic = { version = "1.5", features = ["critical-section"] }
embedded-hal-async = "1.0.0"
embedded-hal-bus = { version = "0.2.0", features = ["async"] }
embedded-io-async = { version = "0.6.1", features = ["defmt-03"] }
embedded-storage = "0.3.1"
static_cell = "2.1.0"
log = "0.4"
pio-proc = "0.2.2"
pio = "0.2.1"
rand = { version = "0.8.5", default-features = false }
mqtt-protocol = { path = "../mqtt-protocol" }
serial-comm = { path = "../serial-comm" }
[profile.release]
debug = 2

110
controller/README.md Normal file
View File

@ -0,0 +1,110 @@
# Pico with Embassy
Read temperature values and write them to an MQTT topic.
The Raspberry Pi Pico is well supported by [embassy](https://embassy.dev), apart
from the bluetooth stack (for [legal]() reasons). As we do not use bluetooth for
communication we can ignore that.
## Flashing
There are two ways of getting the code onto the microcontroller.
### elf2uf2
Compile the binary as normal, convert it into a uf2 firmware which is flashable
to the pico with only an USB connection. Disadvantage of this apporach is that
it is more annoying to do and does not lend itself to debugging.
- Clone the [elf2uf2](https://github.com/rej696/elf2uf2) repository and follow
its instructions to compile the tool
- Compile the controller code with `cargo build --release`
- Convert the resulting binary with something like
`elf2uf2 target/thumbv6m-none-eabi/release/controller ./controller.uf2`
- Hold the bootselect button of the pico when plugging it in
- Copy the uf2 file to the mass storage device
### CMSIS-DAP
Use the [CMSIS-DAP](https://arm-software.github.io/CMSIS_5/DAP/html/index.html)
protocol for flashing and debugging.
The
[raspberry pi debug probe](https://www.raspberrypi.com/documentation/microcontrollers/debug-probe.html)
works well but anything implementing that protocol is fine.
![Debug Probe Setup](./debug-probe-setup.jpg) _Example setup with a Pico WH
(Pico W works as well, just a bit more annoying)_
- Setup debug probe
- Install [probe-rs](https://github.com/probe-rs/probe-rs/)
- Run `cargo run` and it should upload and logs be visible
## Configuration
Configuration is done by sending commands across a serial connection. Only one
command is implemented for now: Set-config with two parameters.
A sample set-config command looks like this (not encoded yet): `SC ssid MyNet`.
It consists of three parts:
- `SC`: Command prefix, always the same.
- `ssid`: Configuration key.
- `MyNet`: Configuration value.
The following keys are recognized:
- `ssid`: Name of the network to connect to.
- `ssid_pw`: Password to connect to the network.
- `mqtt`: URL of the MQTT broker (must not use https).
- `client_id`: ID of the device (used for identifaction in MQTT).
A message needs to be encoded into its byte representation looking on a high
level as follows:
```
|<parameters>|<parameter-lengths>|<prefix>|<parameters>|
```
- `Parameters` is an unsigned byte signifying the amount of parameters in the
message.
- `Parameter-Lengths`: Length of each parameter.
- `Prefix` is always two bytes long and encoded as UTF-8 (meaning it is
basically ASCII).
- `Parameters`: every parameter encodes is own length in its first byte,
followed by the actual data.
Taking the above ssid example this would lead to this (the prefix counts as its
own parameter):
```
0x03 0x02 0x04 0x05 0x53 0x43 0x73 0x73 0x69 0x64 0x4D 0x59 0x4E 0x65 0x74
```
Now the COBS encoding is applied before sending it across the wire:
```
0x10 0x03 0x02 0x04 0x05 0x53 0x43 0x73 0x73 0x69 0x64 0x4d 0x79 0x4e 0x65 0x74
```
In this case (as in most) it merely adds two additional bytes (the last 0x00
byte is implied).
Sending this this command on linux, assuming the serial connection is accessible
on `/dev/ttyACM1`:
```
echo -en "\x10\x03\x02\x04\x05\x53\x43\x73\x73\x69\x64\x4d\x79\x4e\x65\x74\x00" > /dev/ttyACM1
```
If listening to the serial connection, command acknowledgements like `OK` or
`ERROR` can be seen.
In order to decode a message, apply the above steps in reverse order.
All configuration is read on startup of the microcontroller.
## Outlook
- Pull the flash/config handling into separate library for testability
- Merge different ways of running tasks (join and tasks)

36
controller/build.rs Normal file
View File

@ -0,0 +1,36 @@
//! This build script copies the `memory.x` file from the crate root into
//! a directory where the linker can always find it at build time.
//! For many projects this is optional, as the linker always searches the
//! project root directory -- wherever `Cargo.toml` is. However, if you
//! are using a workspace or have a more complicated build setup, this
//! build script becomes required. Additionally, by requesting that
//! Cargo re-run the build script whenever `memory.x` is changed,
//! updating `memory.x` ensures a rebuild of the application with the
//! new memory settings.
use std::env;
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
fn main() {
// Put `memory.x` in our output directory and ensure it's
// on the linker search path.
let out = &PathBuf::from(env::var_os("OUT_DIR").unwrap());
File::create(out.join("memory.x"))
.unwrap()
.write_all(include_bytes!("memory.x"))
.unwrap();
println!("cargo:rustc-link-search={}", out.display());
// By default, Cargo will re-run a build script whenever
// any file in the project changes. By specifying `memory.x`
// here, we ensure the build script is only re-run when
// `memory.x` is changed.
println!("cargo:rerun-if-changed=memory.x");
println!("cargo:rustc-link-arg-bins=--nmagic");
println!("cargo:rustc-link-arg-bins=-Tlink.x");
println!("cargo:rustc-link-arg-bins=-Tlink-rp.x");
println!("cargo:rustc-link-arg-bins=-Tdefmt.x");
}

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,49 @@
Permissive Binary License
Version 1.0, July 2019
Redistribution. Redistribution and use in binary form, without
modification, are permitted provided that the following conditions are
met:
1) Redistributions must reproduce the above copyright notice and the
following disclaimer in the documentation and/or other materials
provided with the distribution.
2) Unless to the extent explicitly permitted by law, no reverse
engineering, decompilation, or disassembly of this software is
permitted.
3) Redistribution as part of a software development kit must include the
accompanying file named <20>DEPENDENCIES<45> and any dependencies listed in
that file.
4) Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
Limited patent license. The copyright holders (and contributors) grant a
worldwide, non-exclusive, no-charge, royalty-free patent license to
make, have made, use, offer to sell, sell, import, and otherwise
transfer this software, where such license applies only to those patent
claims licensable by the copyright holders (and contributors) that are
necessarily infringed by this software. This patent license shall not
apply to any combinations that include this software. No hardware is
licensed hereunder.
If you institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the software
itself infringes your patent(s), then your rights granted under this
license shall terminate as of the date such litigation is filed.
DISCLAIMER. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
CONTRIBUTORS "AS IS." ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT
NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@ -0,0 +1,9 @@
# WiFi firmware
Firmware obtained from https://github.com/Infineon/wifi-host-driver/tree/master/WiFi_Host_Driver/resources/firmware/COMPONENT_43439
Licensed under the [Infineon Permissive Binary License](./LICENSE-permissive-binary-license-1.0.txt)
## Changelog
* 2023-07-28: synced with `ad3bad0` - Update 43439 fw from 7.95.55 ot 7.95.62

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

5
controller/memory.x Normal file
View File

@ -0,0 +1,5 @@
MEMORY {
BOOT2 : ORIGIN = 0x10000000, LENGTH = 0x100
FLASH : ORIGIN = 0x10000100, LENGTH = 2048K - 0x100
RAM : ORIGIN = 0x20000000, LENGTH = 256K
}

View File

@ -0,0 +1,78 @@
use defmt::{error, info};
use embassy_rp::usb::{Driver, Instance};
use embassy_usb::{class::cdc_acm::CdcAcmClass, driver::EndpointError};
use serial_comm::{
cobs::SENTINEL,
protocol::{cmd::Cobs, error::ErrorCommand, ok::OkCommand},
};
use crate::{
cmd_handlers::{handle_cmd, PACKET_SIZE},
flash::Config,
usb_serial::DeviceClass,
};
pub struct Disconnected;
impl From<EndpointError> for Disconnected {
fn from(val: EndpointError) -> Self {
match val {
EndpointError::BufferOverflow => panic!("Buffer overflow"),
EndpointError::Disabled => Disconnected {},
}
}
}
#[embassy_executor::task]
pub async fn cmd_task(class: &'static mut DeviceClass, config: &'static mut Config<'static>) -> ! {
loop {
class.wait_connection().await;
info!("Connected");
let _ = handle_bytes(class, config).await;
info!("Disconnected");
}
}
/// Handle incoming bytes over the usb-serial connection.
///
/// Always buffer data until you encounter the `SENTINEL` value. After that try to decode and
/// execute the received command.
pub async fn handle_bytes<'d, T: Instance + 'd>(
class: &mut CdcAcmClass<'d, Driver<'d, T>>,
config: &mut Config<'d>,
) -> Result<(), Disconnected> {
let mut cmd_buffer: [u8; PACKET_SIZE] = [0; PACKET_SIZE];
let mut cursor: usize = 0;
loop {
let mut read_buf: [u8; 64] = [0; 64];
let n = class.read_packet(&mut read_buf).await?;
let data = &read_buf[..n];
if n > 0 {
cmd_buffer[cursor..cursor + n].copy_from_slice(data);
cursor += n;
if data[n - 1] == SENTINEL {
match handle_cmd(cmd_buffer, cursor, config) {
Err(e) => {
error!(
"Failed to handle command: {:?} - {:?}",
e,
cmd_buffer[0..cursor]
);
let err_cmd = ErrorCommand::new("Failed to handle command");
let (length, err_cmd): (usize, [u8; 128]) = err_cmd.as_cobs();
class.write_packet(&err_cmd[0..length]).await?;
}
_ => {
let ok_cmd = OkCommand::new();
let (length, ok_cmd): (usize, [u8; 128]) = ok_cmd.as_cobs();
class.write_packet(&ok_cmd[0..length]).await?;
}
};
cursor = 0;
}
}
}
}

View File

@ -0,0 +1,72 @@
//! Handle specific commands, comming in from usb-serial.
pub mod set_config;
use defmt::Format;
use serial_comm::{cobs, protocol::set_config::SetConfig};
use crate::flash::{Config, FlashError};
#[derive(Debug)]
pub struct Utf8Error(pub core::str::Utf8Error);
impl Format for Utf8Error {
fn format(&self, f: defmt::Formatter) {
defmt::write!(f, "Utf8Error");
}
}
#[derive(Debug, Format)]
pub enum CmdError {
Utf8Error(Utf8Error),
FlashError(FlashError),
NoSuchCmd,
}
impl From<core::str::Utf8Error> for CmdError {
fn from(err: core::str::Utf8Error) -> Self {
CmdError::Utf8Error(Utf8Error(err))
}
}
impl From<FlashError> for CmdError {
fn from(err: FlashError) -> Self {
CmdError::FlashError(err)
}
}
// arbitrarily constrained, we know we don't need more for now
pub const PACKET_SIZE: usize = 256;
type CmdHandler = fn([u8; PACKET_SIZE], &mut Config) -> Result<(), CmdError>;
static CMD_HANDLERS: &[(&str, CmdHandler)] = &[(SetConfig::PREFIX, set_config::handle)];
/// Try decoding a command buffer and run it.
///
/// On failure return an ErrorCommand across the serial connection, otherwise an OkCommand.
pub fn handle_cmd(
cmd_buffer: [u8; PACKET_SIZE],
length: usize,
config: &mut Config<'_>,
) -> Result<(), CmdError> {
let (length, decoded_cmd): (usize, [u8; PACKET_SIZE]) = cobs::decode(cmd_buffer, length);
if length > 1 {
let header_length = (decoded_cmd[0] + 1) as usize;
let cmd_prefix = core::str::from_utf8(&decoded_cmd[header_length..header_length + 2])?;
let mut idx = 0;
while idx < CMD_HANDLERS.len() && CMD_HANDLERS[idx].0 != cmd_prefix {
idx += 1;
}
if idx == CMD_HANDLERS.len() {
return Err(CmdError::NoSuchCmd);
}
let handler = CMD_HANDLERS[idx].1;
handler(decoded_cmd, config)?;
}
Ok(())
}

View File

@ -0,0 +1,17 @@
use defmt::info;
use serial_comm::protocol::{cmd::Cmd, set_config::SetConfig};
use crate::{cmd_handlers::CmdError, flash::Config};
use super::PACKET_SIZE;
/// Handle a SetConfig command by writing the received key-value pair to flash storage.
pub fn handle(data: [u8; PACKET_SIZE], config: &mut Config<'_>) -> Result<(), CmdError> {
let mut cmd_buffer: [u8; PACKET_SIZE] = [0; PACKET_SIZE];
let set_config = SetConfig::from_bytes::<PACKET_SIZE>(data, &mut cmd_buffer)?;
config.write_config(set_config.key, set_config.value)?;
info!("set {} to {}", set_config.key, set_config.value);
Ok(())
}

124
controller/src/flash.rs Normal file
View File

@ -0,0 +1,124 @@
use defmt::{error, Format};
use embassy_rp::{
flash::{self, Async, Flash, ERASE_SIZE},
peripherals::{DMA_CH0, FLASH},
};
use crate::cmd_handlers::Utf8Error;
const ADDR_OFFSET: u32 = 0x100000;
const FLASH_SIZE: usize = 2 * 1024 * 1024;
pub const ENTRY_SIZE: u32 = ERASE_SIZE as u32;
const SSID_CONFIG: u32 = ADDR_OFFSET;
const SSID_PW_CONFIG: u32 = ADDR_OFFSET + ENTRY_SIZE;
const MQTT_CONFIG: u32 = ADDR_OFFSET + 2 * ENTRY_SIZE;
const CLIENT_ID_CONFIG: u32 = ADDR_OFFSET + 3 * ENTRY_SIZE;
pub const SSID_KEY: &str = "ssid";
pub const SSID_PW_KEY: &str = "ssid_pw";
pub const MQTT_KEY: &str = "mqtt";
pub const CLIENT_ID_KEY: &str = "client_id";
#[derive(Debug, Format)]
pub enum FlashError {
Internal(flash::Error),
Utf8Error(Utf8Error),
NoSuchKey,
ValueTooLong,
NoValue,
}
impl From<flash::Error> for FlashError {
fn from(err: flash::Error) -> Self {
FlashError::Internal(err)
}
}
impl From<core::str::Utf8Error> for FlashError {
fn from(err: core::str::Utf8Error) -> Self {
FlashError::Utf8Error(Utf8Error(err))
}
}
/// Write key-value data to flash storage for persistence and read it from there again.
pub struct Config<'a> {
flash: Flash<'a, FLASH, Async, FLASH_SIZE>,
}
impl<'a> Config<'a> {
/// Initialize the flash module on the RP2040.
pub fn init(flash: FLASH, dma_ch0: DMA_CH0) -> Self {
let flash = embassy_rp::flash::Flash::<_, Async, FLASH_SIZE>::new(flash, dma_ch0);
Self { flash }
}
/// Get the position on flash for a configuration key.
fn get_config_cursor(&self, key: &str) -> Option<u32> {
match key {
SSID_KEY => Some(SSID_CONFIG),
SSID_PW_KEY => Some(SSID_PW_CONFIG),
MQTT_KEY => Some(MQTT_CONFIG),
CLIENT_ID_KEY => Some(CLIENT_ID_CONFIG),
_ => None,
}
}
/// Write a key-value pair to flash storage.
///
/// The key must be one of the predefined ones and the value length can not be longer than
/// `ENTRY_SIZE`.
///
/// Before writing, the whole sector must be erased, otherwise garbage writes can occur. For
/// that reason `ENTRY_SIZE` is set to the ERASE_SIZE of the RP2040 (4096 bits), otherwise we
/// would get misaligned erasures.
///
/// Each key-vaue pair is encoded as [<value_length>,<value>], value_length being one byte
/// long.
pub fn write_config(&mut self, key: &str, val: &str) -> Result<(), FlashError> {
let entry_cursor = self.get_config_cursor(key).ok_or(FlashError::NoSuchKey)?;
let val_data = val.as_bytes();
if val_data.len() > ENTRY_SIZE as usize {
error!(
"data of length {} for {} is longer than the max entry size of {}",
val_data.len(),
key,
ENTRY_SIZE
);
return Err(FlashError::ValueTooLong);
}
self.flash
.blocking_erase(entry_cursor, entry_cursor + ERASE_SIZE as u32)?;
self.flash
.blocking_write(entry_cursor, &[val_data.len() as u8])?; // value length
self.flash.blocking_write(entry_cursor + 1, val_data)?;
Ok(())
}
/// Read the value of a key from flash storage.
pub fn read_config<'b>(
&mut self,
key: &str,
buffer: &'b mut [u8; ENTRY_SIZE as usize],
) -> Result<&'b str, FlashError> {
let entry_cursor = self.get_config_cursor(key).ok_or(FlashError::NoSuchKey)?;
let mut length_buffer: [u8; 1] = [0; 1];
self.flash.blocking_read(entry_cursor, &mut length_buffer)?;
let length = length_buffer[0] as usize;
if length < 1 || length > ENTRY_SIZE as usize {
error!("there is no valid value for {}", key);
return Err(FlashError::NoValue);
}
self.flash.blocking_read(entry_cursor + 1, buffer)?;
let val = core::str::from_utf8(&buffer[0..length])?;
Ok(val)
}
}

101
controller/src/main.rs Normal file
View File

@ -0,0 +1,101 @@
//! Read the temperature at regular intervals and publish that data via MQTT:
//! Configure the microcontroller with commands sent across an usb-serial connection.
#![no_std]
#![no_main]
use core::str::FromStr;
use byte_handler::cmd_task;
use cyw43::Control;
use defmt::{error, info};
use embassy_executor::Spawner;
use embassy_net::Stack;
use embassy_time::Timer;
use flash::{Config, FlashError};
use static_cell::StaticCell;
use temperature::{temperature_task, Temperature};
use usb_serial::{init_usb, usb_task};
use wifi::connect;
use {defmt_rtt as _, panic_probe as _};
use crate::{mqtt::BUFFER_SIZE, wifi::init_wifi};
mod byte_handler;
mod cmd_handlers;
mod flash;
mod mqtt;
mod temperature;
mod usb_serial;
mod wifi;
async fn connect_wifi<'a>(
config: &mut Config<'static>,
wifi: &mut Control<'a>,
net_stack: &'a Stack<cyw43::NetDriver<'a>>,
) -> Result<(), FlashError> {
let mut ssid_buffer = [0; flash::ENTRY_SIZE as usize];
let ssid = config.read_config(flash::SSID_KEY, &mut ssid_buffer)?;
let mut ssid_pw_buffer = [0; flash::ENTRY_SIZE as usize];
let ssid_pw = config.read_config(flash::SSID_PW_KEY, &mut ssid_pw_buffer)?;
if !ssid.is_empty() {
connect(wifi, net_stack, ssid, ssid_pw).await;
}
Ok(())
}
static CONFIG: StaticCell<Config> = StaticCell::new();
static RX_BUFFER: StaticCell<[u8; BUFFER_SIZE]> = StaticCell::new();
static TX_BUFFER: StaticCell<[u8; BUFFER_SIZE]> = StaticCell::new();
static CLIENT_ID_BUFFER: StaticCell<[u8; flash::ENTRY_SIZE as usize]> = StaticCell::new();
static MQTT_IP_BUFFER: StaticCell<[u8; flash::ENTRY_SIZE as usize]> = StaticCell::new();
#[embassy_executor::main]
async fn main(spawner: Spawner) {
let p = embassy_rp::init(Default::default());
Timer::after_millis(100).await;
let config = CONFIG.init(Config::init(p.FLASH, p.DMA_CH0));
let (mut wifi, net_stack) = init_wifi(
spawner, p.PIN_23, p.PIN_24, p.PIN_25, p.PIN_29, p.PIO0, p.DMA_CH1,
)
.await;
match connect_wifi(config, &mut wifi, net_stack).await {
Ok(_) => info!("Connected to WiFi."),
Err(_) => error!("Failed to connect to WiFi"),
}
let rx_buffer = RX_BUFFER.init([0u8; BUFFER_SIZE]);
let tx_buffer = TX_BUFFER.init([0u8; BUFFER_SIZE]);
let client_id_buffer = CLIENT_ID_BUFFER.init([0u8; flash::ENTRY_SIZE as usize]);
let client_id = config
.read_config(flash::CLIENT_ID_KEY, client_id_buffer)
.unwrap_or("tomato");
let mqtt_ip_buffer = MQTT_IP_BUFFER.init([0u8; flash::ENTRY_SIZE as usize]);
let mqtt_ip = config
.read_config(flash::MQTT_KEY, mqtt_ip_buffer)
.unwrap_or("127.0.0.1");
let mqtt = mqtt::Client::new(client_id, net_stack, rx_buffer, tx_buffer);
let host_addr = embassy_net::IpAddress::from_str(mqtt_ip)
.unwrap_or(embassy_net::IpAddress::v4(127, 0, 1, 1));
let temps = Temperature::new(p.ADC, p.ADC_TEMP_SENSOR);
let (usb, class) = init_usb(p.USB);
spawner
.spawn(usb_task(usb))
.expect("failed to run usb task");
spawner
.spawn(cmd_task(class, config))
.expect("failed initializing usb command handler");
spawner
.spawn(temperature_task(client_id, host_addr, mqtt, temps))
.expect("failed starting temperature task");
}

73
controller/src/mqtt.rs Normal file
View File

@ -0,0 +1,73 @@
use defmt::Format;
use embassy_net::{
tcp::{ConnectError, Error, TcpSocket},
IpAddress, Stack,
};
use embassy_time::Duration;
use mqtt_protocol::{connect::Connect, publish::Publish};
pub const BUFFER_SIZE: usize = 4096;
const TCP_TIMEOUT_SECS: u64 = 5;
#[derive(Debug, Format)]
pub enum MqttError {
TcpError(Error),
ConnectError(ConnectError),
}
impl From<ConnectError> for MqttError {
fn from(err: ConnectError) -> Self {
MqttError::ConnectError(err)
}
}
impl From<Error> for MqttError {
fn from(err: Error) -> Self {
MqttError::TcpError(err)
}
}
/// Very simple MQTT client, capable of sending simple publish packets over unencrypted TCP/IP.
pub struct Client<'a> {
pub client_id: &'a str,
socket: TcpSocket<'a>,
}
impl<'a> Client<'a> {
/// Initialize the socket for sending MQTT data and set the socket timeout to `TCP_TIMEOUT_SECS`.
///
/// - `client_id`: Identification of this client.
/// - `net_stack`: network stack for sending data.
/// - `rx_buffer`: byte buffer for receiving data.
/// - `tx_buffer`: byte buffer for sending data.
pub fn new(
client_id: &'a str,
net_stack: &'a Stack<cyw43::NetDriver<'a>>,
rx_buffer: &'static mut [u8; BUFFER_SIZE],
tx_buffer: &'static mut [u8; BUFFER_SIZE],
) -> Self {
let mut socket = embassy_net::tcp::TcpSocket::new(net_stack, rx_buffer, tx_buffer);
socket.set_timeout(Some(Duration::from_secs(TCP_TIMEOUT_SECS)));
Self { client_id, socket }
}
//// Publish the payload `payload` to the MQTT topic `topic` on the broker running at `address`:1883.
pub async fn publish(
&mut self,
address: IpAddress,
topic: &str,
payload: &[u8],
) -> Result<(), MqttError> {
self.socket.connect((address, 1883)).await?;
let connect = Connect::new(self.client_id);
self.socket.write(&connect.data[0..connect.length]).await?;
let publish = Publish::new(topic, payload);
self.socket.write(&publish.data[0..publish.length]).await?;
self.socket.close();
Ok(())
}
}

View File

@ -0,0 +1,86 @@
use defmt::{error, info};
use embassy_net::IpAddress;
use embassy_rp::{
adc::{Adc, Async, Channel, Config, Error, InterruptHandler},
bind_interrupts,
peripherals::{ADC, ADC_TEMP_SENSOR},
};
use embassy_time::Timer;
use mqtt_protocol::publish::MAX_TOPIC_LENGTH;
use crate::mqtt;
bind_interrupts!(struct Irqs {
ADC_IRQ_FIFO => InterruptHandler;
});
#[embassy_executor::task]
pub async fn temperature_task(
client_id: &'static str,
host_addr: IpAddress,
mut mqtt: mqtt::Client<'static>,
mut temps: Temperature<'static>,
) -> ! {
const TOPIC_PREFIX: &str = "temps/";
let mut topic_buffer = [0u8; MAX_TOPIC_LENGTH - TOPIC_PREFIX.len()];
topic_buffer[..TOPIC_PREFIX.len()].copy_from_slice(TOPIC_PREFIX.as_bytes());
let client_id_bytes = client_id.as_bytes();
topic_buffer[TOPIC_PREFIX.len()..TOPIC_PREFIX.len() + client_id_bytes.len()]
.copy_from_slice(client_id_bytes);
let topic_full =
core::str::from_utf8(&topic_buffer[..TOPIC_PREFIX.len() + client_id_bytes.len()])
.unwrap_or("temps/tomato");
loop {
match temps.read().await {
Ok(temp) => {
info!("publishing temperature data...");
if let Err(e) = mqtt
.publish(host_addr, topic_full, &temp.to_le_bytes())
.await
{
error!("failed to publish temperature data: {}", e);
}
}
Err(e) => error!("failed to read temperature: {}", e),
}
Timer::after_secs(10).await;
}
}
/// Convert the raw temperature sensor reading into degrees celsius.
///
/// Taken from the [embassy examples](https://github.com/embassy-rs/embassy/blob/b6fc682117a41e8e63a9632e06da5a17f46d9ab0/examples/rp/src/bin/adc.rs#L43).
fn convert_to_celsius(raw_temp: u16) -> f32 {
// According to chapter 4.9.5. Temperature Sensor in RP2040 datasheet
let temp = 27.0 - (raw_temp as f32 * 3.3 / 4096.0 - 0.706) / 0.001721;
let sign = if temp < 0.0 { -1.0 } else { 1.0 };
let rounded_temp_x10: i16 = ((temp * 10.0) + 0.5 * sign) as i16;
(rounded_temp_x10 as f32) / 10.0
}
pub struct Temperature<'a> {
adc: Adc<'a, Async>,
ts: Channel<'a>,
}
impl<'a> Temperature<'a> {
/// Initialize the temperature sensor.
pub fn new(adc_pin: ADC, adc_temp_pin: ADC_TEMP_SENSOR) -> Self {
let adc = Adc::new(adc_pin, Irqs, Config::default());
let ts = Channel::new_temp_sensor(adc_temp_pin);
Self { adc, ts }
}
/// Read from the temperature sensor and return the value in degree celsius.
pub async fn read(&mut self) -> Result<f32, Error> {
let temp = self.adc.read(&mut self.ts).await?;
let temp = convert_to_celsius(temp);
Ok(temp)
}
}

View File

@ -0,0 +1,74 @@
use embassy_rp::{
bind_interrupts,
peripherals::USB,
usb::{Driver, InterruptHandler},
};
use embassy_usb::{
class::cdc_acm::{CdcAcmClass, State},
Builder, Config, UsbDevice,
};
use static_cell::StaticCell;
bind_interrupts!(struct Irqs {
USBCTRL_IRQ => InterruptHandler<USB>;
});
pub type SerialDevice = UsbDevice<'static, Driver<'static, USB>>;
pub type DeviceClass = CdcAcmClass<'static, Driver<'static, USB>>;
const DESCRIPTOR_SIZE: usize = 256;
type Descriptor = [u8; DESCRIPTOR_SIZE];
const BUFFER_SIZE: usize = 64;
type ControlBuffer = [u8; BUFFER_SIZE];
static DEVICE_DESCRIPTOR: StaticCell<Descriptor> = StaticCell::new();
static CONFIG_DESCRIPTOR: StaticCell<Descriptor> = StaticCell::new();
static BOS_DESCRIPTOR: StaticCell<Descriptor> = StaticCell::new();
static CONTROL_BUFFER: StaticCell<ControlBuffer> = StaticCell::new();
static STATE: StaticCell<State> = StaticCell::new();
static CLASS: StaticCell<DeviceClass> = StaticCell::new();
#[embassy_executor::task]
pub async fn usb_task(mut usb: SerialDevice) -> ! {
usb.run().await
}
/// Initialize the USB serial interface and return the device and CdcAcm class (Abstract Control Model).
pub fn init_usb(usb: USB) -> (SerialDevice, &'static mut DeviceClass) {
let driver = Driver::new(usb, Irqs);
let mut config = Config::new(0xc0de, 0xcafe);
config.manufacturer = Some("Embassy");
config.product = Some("USB-serial");
config.serial_number = Some("123456789");
config.max_power = 100;
config.max_packet_size_0 = 64;
// Required for windows 7 compatibility.
// https://developer.nordicsemi.com/nRF_Connect_SDK/doc/1.9.1/kconfig/CONFIG_CDC_ACM_IAD.html#help
config.device_class = 0xEF;
config.device_sub_class = 0x02;
config.device_protocol = 0x01;
config.composite_with_iads = true;
let device_descriptor = DEVICE_DESCRIPTOR.init([0u8; DESCRIPTOR_SIZE]);
let config_descriptor = CONFIG_DESCRIPTOR.init([0u8; DESCRIPTOR_SIZE]);
let bos_descriptor = BOS_DESCRIPTOR.init([0u8; DESCRIPTOR_SIZE]);
let control_buf = CONTROL_BUFFER.init([0u8; BUFFER_SIZE]);
let state = STATE.init(State::new());
let mut builder = Builder::new(
driver,
config,
device_descriptor,
config_descriptor,
bos_descriptor,
control_buf,
);
let class = CLASS.init(CdcAcmClass::new(&mut builder, state, 64));
let usb = builder.build();
(usb, &mut *class)
}

128
controller/src/wifi.rs Normal file
View File

@ -0,0 +1,128 @@
use cyw43::{Control, ControlError, State};
use cyw43_pio::PioSpi;
use defmt::{error, info, unwrap};
use embassy_executor::Spawner;
use embassy_net::{Config, Stack, StackResources};
use embassy_rp::{
bind_interrupts,
clocks::RoscRng,
gpio::{Level, Output},
peripherals::{DMA_CH1, PIN_23, PIN_24, PIN_25, PIN_29, PIO0},
pio::{InterruptHandler, Pio},
};
use embassy_time::Timer;
use rand::RngCore;
use static_cell::StaticCell;
const MAX_WIFI_TRIES: u8 = 3;
const MAX_DHCP_TRIES: u8 = 20;
bind_interrupts!(struct Irqs {
PIO0_IRQ_0 => InterruptHandler<PIO0>;
});
#[embassy_executor::task]
async fn wifi_task(
runner: cyw43::Runner<'static, Output<'static>, PioSpi<'static, PIO0, 0, DMA_CH1>>,
) -> ! {
runner.run().await
}
#[embassy_executor::task]
async fn net_task(stack: &'static Stack<cyw43::NetDriver<'static>>) -> ! {
stack.run().await
}
static STATE: StaticCell<State> = StaticCell::new();
static STACK: StaticCell<Stack<cyw43::NetDriver<'static>>> = StaticCell::new();
const STACK_RESOURCES_COUNT: usize = 2;
static STACK_RESOURCES: StaticCell<StackResources<STACK_RESOURCES_COUNT>> = StaticCell::new();
// Initialise the wwifi chip of the RP2040 and return the control interface as well as the network
// stack.
pub async fn init_wifi(
spawner: Spawner,
pin_23: PIN_23,
pin_24: PIN_24,
pin_25: PIN_25,
pin_29: PIN_29,
pio0: PIO0,
dma_ch1: DMA_CH1,
) -> (Control<'static>, &'static Stack<cyw43::NetDriver<'static>>) {
let fw = include_bytes!("../cyw43-firmware/43439A0.bin");
let clm = include_bytes!("../cyw43-firmware/43439A0_clm.bin");
let pwr = Output::new(pin_23, Level::Low);
let cs = Output::new(pin_25, Level::High);
let mut pio = Pio::new(pio0, Irqs);
let spi = PioSpi::new(
&mut pio.common,
pio.sm0,
pio.irq0,
cs,
pin_24,
pin_29,
dma_ch1,
);
let state = STATE.init(State::new());
let (net_device, mut control, runner) = cyw43::new(state, pwr, spi, fw).await;
unwrap!(spawner.spawn(wifi_task(runner)));
control.init(clm).await;
control
.set_power_management(cyw43::PowerManagementMode::PowerSave)
.await;
let config = Config::dhcpv4(Default::default());
let mut rng = RoscRng;
let seed = rng.next_u64();
// Init network stack
let resources = STACK_RESOURCES.init(StackResources::<STACK_RESOURCES_COUNT>::new());
let stack = &*STACK.init(Stack::new(net_device, config, resources, seed));
unwrap!(spawner.spawn(net_task(stack)));
(control, stack)
}
/// Connect to a WiFi network using WPA2 and try to get an IP from DHCP (IPv4).
pub async fn connect<'a>(
wifi: &mut Control<'a>,
net_stack: &Stack<cyw43::NetDriver<'a>>,
ssid: &str,
pw: &str,
) {
info!("trying to connect to {}...", ssid);
let mut idx = 0;
let mut status: Result<(), ControlError> = Err(ControlError { status: 0 });
while idx < MAX_WIFI_TRIES && status.is_err() {
status = wifi.join_wpa2(ssid, pw).await;
Timer::after_millis(100).await;
idx += 1;
}
if let Err(e) = status {
error!("WiFi connection failed: {}", e.status);
} else {
info!("waiting for DHCP...");
idx = 0;
while idx < MAX_DHCP_TRIES && !net_stack.is_config_up() {
Timer::after_millis(1000).await;
idx += 1;
}
if net_stack.is_config_up() {
info!("DHCP is up!");
} else {
error!("Failed to get an IP from DHCP");
}
}
}

114
flake.lock Normal file
View File

@ -0,0 +1,114 @@
{
"nodes": {
"fenix": {
"inputs": {
"nixpkgs": "nixpkgs",
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1731738660,
"narHash": "sha256-tIXhc9lX1b030v812yVJanSR37OnpTb/OY5rU3TbShA=",
"owner": "nix-community",
"repo": "fenix",
"rev": "e10ba121773f754a30d31b6163919a3e404a434f",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "fenix",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1731319897,
"narHash": "sha256-PbABj4tnbWFMfBp6OcUK5iGy1QY+/Z96ZcLpooIbuEI=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "dc460ec76cbff0e66e269457d7b728432263166c",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1731319897,
"narHash": "sha256-PbABj4tnbWFMfBp6OcUK5iGy1QY+/Z96ZcLpooIbuEI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "dc460ec76cbff0e66e269457d7b728432263166c",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"fenix": "fenix",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs_2"
}
},
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1731693936,
"narHash": "sha256-uHUUS1WPyW6ohp5Bt3dAZczUlQ22vOn7YZF8vaPKIEw=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "1b90e979aeee8d1db7fe14603a00834052505497",
"type": "github"
},
"original": {
"owner": "rust-lang",
"ref": "nightly",
"repo": "rust-analyzer",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

37
flake.nix Normal file
View File

@ -0,0 +1,37 @@
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
fenix.url = "github:nix-community/fenix";
};
outputs =
{
nixpkgs,
fenix,
flake-utils,
...
}:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = import nixpkgs { inherit system; };
fx = fenix.packages.${system};
rust = fx.combine [
fx.stable.toolchain
fx.targets.thumbv6m-none-eabi.stable.rust-std
];
buildInputs = [
rust
pkgs.cargo-deny
pkgs.rust-analyzer
pkgs.probe-rs
];
in
{
devShells.default = pkgs.mkShell {
buildInputs = buildInputs;
};
}
);
}

8
mqtt-protocol/Cargo.toml Normal file
View File

@ -0,0 +1,8 @@
[package]
name = "mqtt-protocol"
version.workspace = true
edition.workspace = true
description.workspace = true
license.workspace = true
[dependencies]

View File

@ -0,0 +1,128 @@
use crate::fixed_header::FixedHeader;
// [MQTT 5.0: 3.1.3.1](https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Toc385349242)
const ALLOWED_CLIENT_ID_CHARS: &str =
"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
// [MQTT 5.0: 3.1.3.1](https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Toc385349242)
const MAX_CLIENT_ID_SIZE: usize = 23;
// [MQTT 5.0: 3.1.1](https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_CONNECT_Fixed_Header)
const FIXED_CONNECT_TYPE: u8 = 0x01;
// [MQTT 5.0: 3.1.1](https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_CONNECT_Fixed_Header)
const FIXED_CONNECT_FLAGS: u8 = 0x00;
// "MQTT" [MQTT 5.0: 3.1.2.1](https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Toc385349225)
const PROTOCOL_NAME: [u8; 6] = [0x00, 0x04, 0x4d, 0x51, 0x54, 0x54];
// "version 5" [MQTT 5.0: 3.1.2.2](https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Toc385349227)
const PROTOCOL_VERSION: [u8; 1] = [0x05];
// no user name, no will qos, no will, no clean start [MQTT 5.0: 3.1.2.3 - 3.1.2.9](https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Toc385349229)
const CONNECT_FLAGS: [u8; 1] = [0x00];
// turn off keep alive mechanism [MQTT 5.0: 3.1.2.10](https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Keep_Alive_1)
const KEEP_ALIVE: [u8; 2] = [0x00, 0x00];
// no properties [MQTT 5.0: 3.1.2.11](https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Toc511988523)
const PROPERTIES: [u8; 1] = [0x00];
// calculate position in the data array (those that are constant)
const PROTOCOL_NAME_IDX: usize = 0;
const PROTOCOL_VERSION_IDX: usize = PROTOCOL_NAME_IDX + PROTOCOL_NAME.len();
const CONNECT_FLAGS_IDX: usize = PROTOCOL_VERSION_IDX + PROTOCOL_VERSION.len();
const KEEP_ALIVE_IDX: usize = CONNECT_FLAGS_IDX + CONNECT_FLAGS.len();
const PROPERTIES_IDX: usize = KEEP_ALIVE_IDX + KEEP_ALIVE.len();
const PAYLOAD_IDX: usize = PROPERTIES_IDX + PROPERTIES.len();
// max size the variable header + payload of a connect packet
const MAX_PACKET_LENGTH: usize = PROTOCOL_NAME.len()
+ PROTOCOL_VERSION.len()
+ CONNECT_FLAGS.len()
+ KEEP_ALIVE.len()
+ PROPERTIES.len()
+ MAX_CLIENT_ID_SIZE
+ 2; // two bytes for the client id length
// max size a whole connect packet can be
const MAX_CONNECT_LENGTH: usize = MAX_PACKET_LENGTH + 5; // 5 is the max length of the fixed header
pub struct Connect {
// byte representation of the connect packet
pub data: [u8; MAX_CONNECT_LENGTH],
// actual length of the whole connect packet
pub length: usize,
}
impl Connect {
/// Create an MQTT connect packet.
///
/// See [MQTT 5.0: 3.1](https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_CONNECT_%E2%80%93_Connection)
/// for the byte structure.
///
/// `client_id` is how the client identifies itself. According to
/// [MQTT 5.0: 3.1.3.1]((https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Toc385349242)
/// this is not strictly necessary but a server does not have to accept this.
///
/// The same holds for the limitation of 23 bytes for the `client_id`.
pub fn new(client_id: &str) -> Self {
assert!(client_id
.chars()
.all(|x| ALLOWED_CLIENT_ID_CHARS.contains(x)));
let client_id_data = client_id.as_bytes();
assert!(!client_id_data.is_empty() && client_id_data.len() <= MAX_CLIENT_ID_SIZE);
let client_id_length = client_id_data.len() as u16;
let high_byte = ((client_id_length & 0xFF00) >> 8) as u8;
let low_byte = (client_id_length & 0x00FF) as u8;
let mut payload_data = [0u8; MAX_CLIENT_ID_SIZE + 2];
payload_data[0] = high_byte;
payload_data[1] = low_byte;
payload_data[2..2 + client_id_data.len()].copy_from_slice(client_id_data);
let packet_length = MAX_PACKET_LENGTH - MAX_CLIENT_ID_SIZE + client_id_data.len();
let fixed_header = FixedHeader::new(
FIXED_CONNECT_TYPE,
FIXED_CONNECT_FLAGS,
packet_length as u32,
);
let mut data = [0u8; MAX_CONNECT_LENGTH];
data[0] = fixed_header.type_flags;
data[1..1 + fixed_header.length]
.copy_from_slice(&fixed_header.remaining_length[0..fixed_header.length]);
let fixed_offset = fixed_header.length + 1;
data[fixed_offset + PROTOCOL_NAME_IDX..fixed_offset + PROTOCOL_VERSION_IDX]
.copy_from_slice(&PROTOCOL_NAME);
data[fixed_offset + PROTOCOL_VERSION_IDX..fixed_offset + CONNECT_FLAGS_IDX]
.copy_from_slice(&PROTOCOL_VERSION);
data[fixed_offset + CONNECT_FLAGS_IDX..fixed_offset + KEEP_ALIVE_IDX]
.copy_from_slice(&CONNECT_FLAGS);
data[fixed_offset + KEEP_ALIVE_IDX..fixed_offset + PROPERTIES_IDX]
.copy_from_slice(&KEEP_ALIVE);
data[fixed_offset + PROPERTIES_IDX..fixed_offset + PAYLOAD_IDX]
.copy_from_slice(&PROPERTIES);
data[fixed_offset + PAYLOAD_IDX..fixed_offset + PAYLOAD_IDX + client_id_data.len() + 2]
.copy_from_slice(&payload_data[0..client_id_data.len() + 2]);
Self {
data,
length: packet_length + fixed_header.length + 1,
}
}
}
#[cfg(test)]
mod tests {
use super::Connect;
#[test]
fn test_encode_decode_length_1_byte() {
let connect = Connect::new("Tomato");
assert_eq!(
connect.data[0..connect.length],
[
0x10, 0x13, 0x00, 0x04, 0x4d, 0x51, 0x54, 0x54, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00,
0x06, 0x54, 0x6f, 0x6d, 0x61, 0x74, 0x6f
]
)
}
}

View File

@ -0,0 +1,28 @@
use super::variable_length::encode_length;
pub struct FixedHeader {
pub type_flags: u8,
// length of variable header + payload, encoded as a variable byte integer
// ([MQTT 5.0: 1.5.5](https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Toc473619950))
pub remaining_length: [u8; 4],
// how many bytes in the remaining length field are relevant (left to right)
pub length: usize,
}
impl FixedHeader {
/// Create a fixed header ([MQTT 5.0: 2.1.1](https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Toc511988498)) for an MQTT packet.
///
/// - `packet_type`: [MQTT 5.0: 2.1.2](https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Toc353481061)
/// - `flags`: [MQTT 5.0: 2.1.3](https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Toc353481062)
/// - `remaining_length`: Length of the variable header + payload.
pub fn new(packet_type: u8, flags: u8, remaining_length: u32) -> Self {
let mut remaining_length_data = [0u8; 4];
let length = encode_length(remaining_length, &mut remaining_length_data);
Self {
type_flags: packet_type << 4 | (flags & 0x0F),
remaining_length: remaining_length_data,
length: length.into(),
}
}
}

10
mqtt-protocol/src/lib.rs Normal file
View File

@ -0,0 +1,10 @@
//! Implement just enough of the MQTT protocol to be able to publish data with
//! QOS 0.
//! All formats are taken from [MQTT Version 5.0](https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html).
#![cfg_attr(not(test), no_std)]
pub mod connect;
pub mod fixed_header;
pub mod publish;
pub mod variable_length;

View File

@ -0,0 +1,80 @@
use crate::fixed_header::FixedHeader;
// [MQTT 5.0: 3.3.1](https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Toc359155681)
const FIXED_PUBLISH_TYPE: u8 = 0x03;
// no flags set [MQTT 5.0: 3.3.1](https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Toc359155681)
const FIXED_PUBLISH_FLAGS: u8 = 0x00;
// no properties [MQTT 5.0: 3.3.2.3](https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Toc511988586)
const PROPERTIES: [u8; 1] = [0x00];
// technically this is 65535 (see https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_UTF-8_Encoded_String)
// but we do not need that here
pub const MAX_TOPIC_LENGTH: usize = 254;
// this is again arbitrarilly constricted, technically it could be as long as the variable length
// encoding allows (minus fixed and variable header)
const MAX_PAYLOAD_LENGTH: usize = 128;
// 7 is 5 + 2 which is max length of the fixed header and the two bytes used to encode the topic
// length
const MAX_PUBLISH_LENGTH: usize = MAX_TOPIC_LENGTH + 7 + MAX_PAYLOAD_LENGTH;
/// Encode a string to an MQTT length encoded string
/// [MQTT 5.0: 1.5.4](https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Toc462729066)
fn encode_mqtt_str(topic: &str, encode_buffer: &mut [u8; MAX_TOPIC_LENGTH]) -> usize {
let topic_data = topic.as_bytes();
assert!(topic_data.len() <= MAX_TOPIC_LENGTH);
encode_buffer[0] = 0; // no need for the high bits as our max topic length is too small
encode_buffer[1] = topic_data.len() as u8;
encode_buffer[2..2 + topic_data.len()].copy_from_slice(topic_data);
topic_data.len() + 2
}
pub struct Publish {
// byte representation of the publish packet
pub data: [u8; MAX_PUBLISH_LENGTH],
// actual length of the whole publish packet
pub length: usize,
}
impl Publish {
/// Create an MQTT publish packet.
///
/// See [MQTT 5.0: 3.3](https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Toc384800410)
/// for the byte structure.
///
/// `topic`: defines the topic to publish to
/// `payload` value to publish as is
pub fn new(topic: &str, payload: &[u8]) -> Self {
let mut topic_data = [0u8; MAX_TOPIC_LENGTH];
let topic_length = encode_mqtt_str(topic, &mut topic_data);
let packet_length = PROPERTIES.len() + topic_length + 2 + payload.len();
let fixed_header = FixedHeader::new(
FIXED_PUBLISH_TYPE,
FIXED_PUBLISH_FLAGS,
packet_length as u32,
);
let mut data = [0u8; MAX_PUBLISH_LENGTH];
data[0] = fixed_header.type_flags;
data[1..1 + fixed_header.length]
.copy_from_slice(&fixed_header.remaining_length[0..fixed_header.length]);
let fixed_offset = fixed_header.length + 1;
let topic_idx = fixed_offset;
let properties_idx = topic_idx + topic_length;
let payload_idx = properties_idx + PROPERTIES.len();
data[topic_idx..properties_idx].copy_from_slice(&topic_data[0..topic_length]);
data[properties_idx..payload_idx].copy_from_slice(&PROPERTIES);
data[payload_idx..payload_idx + payload.len()].copy_from_slice(payload);
Self {
data,
length: packet_length + fixed_header.length + 1,
}
}
}

View File

@ -0,0 +1,94 @@
/// Encode an integer as a variable byte integer
/// [MQTT 5.0: 1.5.5](https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Toc473619950)
pub fn encode_length(length: u32, encoded_length: &mut [u8; 4]) -> u8 {
let mut length = length;
let mut idx = 0;
while idx < encoded_length.len() && length > 0 {
let mut encoded_byte = (length % 128) as u8;
length /= 128;
if length > 0 {
encoded_byte |= 0x80; // Set the highest bit
}
encoded_length[idx] = encoded_byte;
idx += 1;
}
idx as u8
}
/// Decode a variable byte integer as an integer.
/// [MQTT 5.0: 1.5.5](https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Toc473619950)
pub fn decode_length(bytes: [u8; 4]) -> u32 {
let mut length = 0u32;
let mut multiplier = 1u32;
let mut idx = 0;
let mut byte = bytes[idx];
while idx < bytes.len() && byte & 0x80 != 0 {
length += ((byte & 0x7F) as u32) * multiplier;
multiplier *= 128;
idx += 1;
byte = bytes[idx]
}
length += ((byte & 0x7F) as u32) * multiplier;
length
}
#[cfg(test)]
mod tests {
use crate::variable_length::{decode_length, encode_length};
#[test]
fn test_encode_decode_length_1_byte() {
let mut length = [0u8; 4];
let byte_length = encode_length(16, &mut length);
assert_eq!(byte_length, 1);
assert_eq!(length, [0x10, 0x00, 0x00, 0x00]);
let length = decode_length(length);
assert_eq!(length, 16);
}
#[test]
fn test_encode_decode_length_2_bytes() {
let mut length = [0u8; 4];
let byte_length = encode_length(568, &mut length);
assert_eq!(byte_length, 2);
assert_eq!(length, [0xb8, 0x04, 0x00, 0x00]);
let length = decode_length(length);
assert_eq!(length, 568);
}
#[test]
fn test_encode_decode_length_3_bytes() {
let mut length = [0u8; 4];
let byte_length = encode_length(85734, &mut length);
assert_eq!(byte_length, 3);
assert_eq!(length, [0xe6, 0x9d, 0x05, 0x00]);
let length = decode_length(length);
assert_eq!(length, 85734);
}
#[test]
fn test_encode_length_4_bytes() {
let mut length = [0u8; 4];
let byte_length = encode_length(8573471, &mut length);
assert_eq!(byte_length, 4);
assert_eq!(length, [0x9f, 0xa4, 0x8b, 0x04]);
let length = decode_length(length);
assert_eq!(length, 8573471);
}
}

8
serial-comm/Cargo.toml Normal file
View File

@ -0,0 +1,8 @@
[package]
name = "serial-comm"
version.workspace = true
edition.workspace = true
description.workspace = true
license.workspace = true
[dependencies]

150
serial-comm/src/cobs.rs Normal file
View File

@ -0,0 +1,150 @@
// sentinel value for backage boundaries
// could be anything but 0x00 is used as a default and makes sense
pub const SENTINEL: u8 = 0x00;
// max size of non SENTINEL values before a code split occurs
const SPLIT_BOUNDARY: u8 = 0xff;
/// Encode a byte array of size `INPUT` with an actual data length of`length`
/// to an output array of size `OUTPUT`, using the COBS algorithm.
///
/// Return a tuple of the structure (<encoded_length>, <output_array>)
pub fn encode<const INPUT: usize, const OUTPUT: usize>(
input: [u8; INPUT],
length: usize,
) -> (usize, [u8; OUTPUT]) {
let mut output: [u8; OUTPUT] = [0; OUTPUT];
let mut encode_idx = 1;
let mut code_idx = 0;
let mut code = 1;
for byte in input.iter().take(length) {
if *byte == SENTINEL {
output[code_idx] = code;
code = 1;
code_idx = encode_idx;
encode_idx += 1;
} else {
output[encode_idx] = *byte;
encode_idx += 1;
code += 1;
if code == SPLIT_BOUNDARY {
output[code_idx] = code;
code = 1;
code_idx = encode_idx;
if encode_idx < length {
encode_idx += 1;
}
}
}
}
if code_idx < OUTPUT {
output[code_idx] = code;
}
(encode_idx, output)
}
/// Decode a COBS encoded byte array of length `INPUT` and an actual data length of `length` to an
/// array of length `OUTPUT`.
///
/// Return a tuple of the structure (<decoded_length>, <output_array>)
pub fn decode<const INPUT: usize, const OUTPUT: usize>(
input: [u8; INPUT],
length: usize,
) -> (usize, [u8; OUTPUT]) {
let mut output: [u8; OUTPUT] = [0; OUTPUT];
let mut out_idx = 0;
let mut idx = 0;
while idx < length {
let code = input[idx];
idx += 1;
for _ in 1..code {
if idx < length {
output[out_idx] = input[idx];
out_idx += 1;
idx += 1;
}
}
if code != SPLIT_BOUNDARY && idx < length {
output[out_idx] = SENTINEL;
out_idx += 1;
}
}
(out_idx, output)
}
#[cfg(test)]
mod tests {
use crate::cobs::{decode, encode};
#[test]
fn test_paper_example() {
let input = [
0x45, 0x00, 0x00, 0x2c, 0x4c, 0x79, 0x00, 0x00, 0x40, 0x06, 0x4f, 0x37,
];
let output = [
0x02, 0x45, 0x01, 0x04, 0x2c, 0x4c, 0x79, 0x01, 0x05, 0x40, 0x06, 0x4f, 0x37,
];
assert_eq!(encode(input, 12), (13, output));
assert_eq!(decode(output, 13), (12, input));
}
#[test]
fn test_empty_input() {
let input = [];
let output = [0x01];
assert_eq!(encode(input, 0), (1, output));
assert_eq!(decode(output, 1), (0, input));
}
#[test]
fn test_all_zeros() {
let input = [0x00, 0x00, 0x00];
let output = [0x01, 0x01, 0x01, 0x01];
assert_eq!(encode(input, 3), (4, output));
assert_eq!(decode(output, 4), (3, input));
}
#[test]
fn test_no_zeros() {
let input = [0x01, 0x02, 0x03];
let output = [0x04, 0x01, 0x02, 0x03];
assert_eq!(encode(input, 3), (4, output));
assert_eq!(decode(output, 4), (3, input));
}
#[test]
fn test_no_zeros_in_254_bytes() {
let input: [u8; 254] = [0x01; 254];
// expected output will have the first byte as 0xFF followed by the 255 non-zero bytes.
let mut output: [u8; 255] = [0x01; 255];
output[0] = 0xff;
assert_eq!(encode(input, 254), (255, output));
assert_eq!(decode(output, 255), (254, input));
}
#[test]
fn test_no_zeros_in_254_bytes_and_additional_block() {
let mut input: [u8; 259] = [0x01; 259];
input[254..259].copy_from_slice(&[0x02, 0x00, 0x03, 0x0c, 0x01]);
// expected output will have the first byte as 0xFF followed by the 254 non-zero bytes.
let mut output: [u8; 261] = [0x01; 261];
output[0] = 0xff;
output[255..261].copy_from_slice(&[0x02, 0x02, 0x04, 0x03, 0x0c, 0x01]);
assert_eq!(encode(input, 259), (261, output));
assert_eq!(decode(output, 261), (259, input));
}
}

8
serial-comm/src/lib.rs Normal file
View File

@ -0,0 +1,8 @@
//! Simple communication protocol over a serial connection, leveraging the COBS algorithm for byte
//! stuffing
//! (S. Cheshire and M. Baker, "Consistent overhead byte stuffing," in IEEE/ACM Transactions on Networking, vol. 7, no. 2, pp. 159-172, April 1999, doi: 10.1109/90.769765).
#![cfg_attr(not(test), no_std)]
pub mod cobs;
pub mod protocol;

View File

@ -0,0 +1,50 @@
use core::str::Utf8Error;
const MAX_PARAMS: usize = 8;
pub trait Cobs<'a> {
/// Encode a command with the COBS algorithm.
///
/// Return a tuple of the structure (<encoded_length>, <output_array>)
fn as_cobs<const OUTPUT: usize>(&self) -> (usize, [u8; OUTPUT]);
}
pub trait Cmd<'a> {
fn from_bytes<const INPUT: usize>(
input: [u8; INPUT],
buffer: &'a mut [u8; INPUT],
) -> Result<Self, Utf8Error>
where
Self: Sized;
/// Read parameters from a byte array (not COBS encoded).
///
/// Every parameter is interpreted as an UTF-8 string.
///
/// Return a string array of size `MAX_PARAMS`.
fn params_from_bytes<const INPUT: usize, const PARAMS: usize>(
input: [u8; INPUT],
buffer: &mut [u8; INPUT],
) -> Result<[&str; MAX_PARAMS], Utf8Error> {
buffer.copy_from_slice(&input);
let param_count = buffer[0] as usize;
assert_eq!(param_count, PARAMS);
assert!(param_count <= MAX_PARAMS);
let param_offset = param_count + 1;
let mut param_idx = 0;
let mut params: [&str; MAX_PARAMS] = [""; MAX_PARAMS];
for i in 1..=param_count {
let param_length = buffer[i] as usize;
let param_start = param_idx + param_offset;
let param = &buffer[param_start..param_start + param_length];
let param = core::str::from_utf8(param)?;
params[i - 1] = param;
param_idx += param_length;
}
Ok(params)
}
}

View File

@ -0,0 +1,83 @@
use core::str::Utf8Error;
use crate::cobs;
use super::cmd::{Cmd, Cobs};
/// The error command carries a message.
pub struct ErrorCommand<'a> {
pub prefix: &'a str,
pub msg: &'a str,
}
impl<'a> ErrorCommand<'a> {
const PARAMS: u8 = 2;
const HEADER_LENGTH: usize = Self::PARAMS as usize + 1;
pub fn new(msg: &'a str) -> Self {
Self { prefix: "ER", msg }
}
}
impl<'a> Cobs<'a> for ErrorCommand<'a> {
fn as_cobs<const OUTPUT: usize>(&self) -> (usize, [u8; OUTPUT]) {
let prefix_bytes = self.prefix.as_bytes();
let msg_bytes = self.msg.as_bytes();
let prefix_end = prefix_bytes.len() + Self::HEADER_LENGTH;
let msg_end = prefix_bytes.len() + msg_bytes.len() + Self::HEADER_LENGTH;
let mut output: [u8; OUTPUT] = [0; OUTPUT];
output[0] = ErrorCommand::PARAMS;
output[1] = prefix_bytes.len() as u8;
output[2] = msg_bytes.len() as u8;
output[ErrorCommand::HEADER_LENGTH..prefix_end].copy_from_slice(prefix_bytes);
output[prefix_end..msg_end].copy_from_slice(msg_bytes);
cobs::encode(output, msg_end)
}
}
impl<'a> Cmd<'a> for ErrorCommand<'a> {
fn from_bytes<const INPUT: usize>(
input: [u8; INPUT],
buffer: &'a mut [u8; INPUT],
) -> Result<Self, Utf8Error> {
let params = <ErrorCommand<'a> as Cmd>::params_from_bytes::<
INPUT,
{ ErrorCommand::PARAMS as usize },
>(input, buffer)?;
Ok(Self {
prefix: params[0],
msg: params[1],
})
}
}
#[cfg(test)]
mod tests {
use super::ErrorCommand;
use crate::cobs;
use crate::protocol::cmd::{Cmd, Cobs};
#[test]
fn test_ok_cmd() {
let cmd = ErrorCommand::new("error");
let (length, cmd_cobs) = cmd.as_cobs::<64>();
assert_eq!(length, 11);
assert_eq!(
&cmd_cobs[0..11],
&[0x0b, 0x02, 0x02, 0x05, 0x45, 0x52, 0x65, 0x72, 0x72, 0x6f, 0x72]
);
let (_, decoded): (usize, [u8; 64]) = cobs::decode(cmd_cobs, 11);
let mut cmd_buffer: [u8; 64] = [0; 64];
let cmd = ErrorCommand::from_bytes::<64>(decoded, &mut cmd_buffer).unwrap();
assert_eq!(cmd.prefix, "ER");
assert_eq!(cmd.msg, "error");
}
}

View File

@ -0,0 +1,6 @@
//! Specific commands for the protocol.
pub mod cmd;
pub mod error;
pub mod ok;
pub mod set_config;

View File

@ -0,0 +1,76 @@
use core::str::Utf8Error;
use crate::cobs;
use super::cmd::{Cmd, Cobs};
// The Ok command has no parameters, it should merely signify something went ok.
pub struct OkCommand<'a> {
pub prefix: &'a str,
}
impl<'a> OkCommand<'a> {
const PARAMS: u8 = 1;
const HEADER_LENGTH: usize = Self::PARAMS as usize + 1;
pub fn new() -> Self {
Self { prefix: "OK" }
}
}
impl<'a> Default for OkCommand<'a> {
fn default() -> Self {
Self::new()
}
}
impl<'a> Cobs<'a> for OkCommand<'a> {
fn as_cobs<const OUTPUT: usize>(&self) -> (usize, [u8; OUTPUT]) {
let prefix_bytes = self.prefix.as_bytes();
let prefix_end = prefix_bytes.len() + Self::HEADER_LENGTH;
let mut output: [u8; OUTPUT] = [0; OUTPUT];
output[0] = OkCommand::PARAMS;
output[1] = prefix_bytes.len() as u8;
output[OkCommand::HEADER_LENGTH..prefix_end].copy_from_slice(prefix_bytes);
cobs::encode(output, prefix_end)
}
}
impl<'a> Cmd<'a> for OkCommand<'a> {
fn from_bytes<const INPUT: usize>(
input: [u8; INPUT],
buffer: &'a mut [u8; INPUT],
) -> Result<Self, Utf8Error> {
let params = <OkCommand<'a> as Cmd>::params_from_bytes::<
INPUT,
{ OkCommand::PARAMS as usize },
>(input, buffer)?;
Ok(Self { prefix: params[0] })
}
}
#[cfg(test)]
mod tests {
use super::OkCommand;
use crate::cobs;
use crate::protocol::cmd::{Cmd, Cobs};
#[test]
fn test_ok_cmd() {
let cmd = OkCommand::new();
let (length, cmd_cobs) = cmd.as_cobs::<64>();
assert_eq!(length, 5);
assert_eq!(&cmd_cobs[0..5], &[0x05, 0x01, 0x02, 0x4f, 0x4b]);
let (_, decoded): (usize, [u8; 64]) = cobs::decode(cmd_cobs, 5);
let mut cmd_buffer: [u8; 64] = [0; 64];
let cmd = OkCommand::from_bytes::<64>(decoded, &mut cmd_buffer).unwrap();
assert_eq!(cmd.prefix, "OK");
}
}

View File

@ -0,0 +1,92 @@
use core::str::Utf8Error;
use crate::cobs;
use super::cmd::{Cmd, Cobs};
#[derive(Debug)]
/// The SetConfig command sets the value of a key.
pub struct SetConfig<'a> {
pub key: &'a str,
pub value: &'a str,
}
impl<'a> SetConfig<'a> {
pub const PREFIX: &'a str = "SC";
const PARAMS: u8 = 3;
const HEADER_LENGTH: usize = Self::PARAMS as usize + 1;
pub fn new(key: &'a str, value: &'a str) -> Self {
Self { key, value }
}
}
impl<'a> Cobs<'a> for SetConfig<'a> {
/// Encode a SetConfig command with the COBS algorithm.
///
/// Return a tuple of the structure (<encoded_length>, <output_array>)
fn as_cobs<const OUTPUT: usize>(&self) -> (usize, [u8; OUTPUT]) {
let prefix_bytes = Self::PREFIX.as_bytes();
let key_bytes = self.key.as_bytes();
let val_bytes = self.value.as_bytes();
let prefix_end = prefix_bytes.len() + Self::HEADER_LENGTH;
let key_end = prefix_end + key_bytes.len();
let val_end = key_end + val_bytes.len();
let mut output: [u8; OUTPUT] = [0; OUTPUT];
output[0] = Self::PARAMS;
output[1] = prefix_bytes.len() as u8;
output[2] = key_bytes.len() as u8;
output[3] = val_bytes.len() as u8;
output[Self::HEADER_LENGTH..prefix_end].copy_from_slice(prefix_bytes);
output[prefix_end..key_end].copy_from_slice(key_bytes);
output[key_end..val_end].copy_from_slice(val_bytes);
cobs::encode(output, val_end)
}
}
impl<'a> Cmd<'a> for SetConfig<'a> {
fn from_bytes<const INPUT: usize>(
input: [u8; INPUT],
buffer: &'a mut [u8; INPUT],
) -> Result<Self, Utf8Error> {
let params = <SetConfig<'a> as Cmd>::params_from_bytes::<
INPUT,
{ SetConfig::PARAMS as usize },
>(input, buffer)?;
Ok(Self {
key: params[1],
value: params[2],
})
}
}
#[cfg(test)]
mod tests {
use crate::cobs;
use crate::protocol::cmd::{Cmd, Cobs};
use crate::protocol::set_config::SetConfig;
#[test]
fn test_config_cmd() {
let cmd = SetConfig::new("ssid", "pw");
let (length, cmd_cobs) = cmd.as_cobs::<64>();
assert_eq!(length, 13);
assert_eq!(
&cmd_cobs[0..13],
&[0x0d, 0x03, 0x02, 0x04, 0x02, 0x53, 0x43, 0x73, 0x73, 0x69, 0x64, 0x70, 0x77]
);
let (_, decoded): (usize, [u8; 64]) = cobs::decode(cmd_cobs, 13);
let mut cmd_buffer: [u8; 64] = [0; 64];
let cmd = SetConfig::from_bytes::<64>(decoded, &mut cmd_buffer).unwrap();
assert_eq!(cmd.key, "ssid");
assert_eq!(cmd.value, "pw");
}
}