Compare commits

...

No commits in common. "main" and "stm32-firmware" have entirely different histories.

43 changed files with 3790 additions and 85 deletions

View file

@ -1,85 +0,0 @@
# mUART Failsafe Interposer
<a href="https://opensource.org"><img height="150px" align="left" src="https://opensource.org/files/OSIApprovedCropped.png" alt="Open Source Initiative Approved License logo"></a>
This repository contains firmware and hardware designs for PCBs that
can be used with the [mUART](https://muart-group.github.io/)
components in [ESPHome](https://esphome.io/). The PCBs provide
interfaces for the UART links to the heat pump/air handler and
thermostat (using CN105-style connectors), and implement a 'failsafe'
mode ensuring that the HP/AH and the thermostat can communicate with
each other even if the ESPHome 'host' is not operating properly (or
not connected at all). They are designed to be interposed between the
heat pump/air handler and the thermostat.
## &nbsp;
## Design
The 'failsafe' feature is implemented by using four analog multiplexer
chips (each chip can route either of two signals to its
input/output). These are used to either connect the UART TX/RX signals
between the HP/AH and thermostat (the 'bypass' mode), or to connect
them to the GPIOs on the ESPHome device (the 'host' mode, so that the
mUART software can communicate with the HP/AH and thermostat). At
power-up, two of the multiplexers connect the UART signals in 'bypass'
mode and the other two multiplexers are disabled.
In addition to the multiplexers there is an
[MSP430](https://www.ti.com/microcontrollers-mcus-processors/msp430-microcontrollers/overview.html)
microcontroller running a very small program (see Firmware below). The
MSP430 can switch the multiplexers into 'host' mode or allow them to
fall back to 'bypass' mode, depending on whether it receives periodic
'alive' signals (pulses on a GPIO) from the ESPHome device. By default
it requires the ESPHome device to send at least one pulse every four
seconds; if no pulse is received during a four-second period, the
multiplexers will switch back to 'bypass' mode. As long as the ESPHome
device's software is operating correctly and sending periodic pulses,
it will be able to communicate with the HP/AH and the thermostat. This
also means that the ESPHome device must send an initial pulse when it
starts up in order to switch the multiplexers into 'host' mode; the
example configuration below shows one way to do that.
## Firmware
The [firmware](../firmware) is a C program which runs on the
MSP430. It can be used with any variant of the MSP430 low-cost
mixed-signal MCUs (FR2000, FR2100, FR2110, or FR2111) with no code
changes.
## Hardware
### Olimex UEXT Adapter
<img height="250px" width="410px" src="../olimex-uext/olimex-uext-front-2024.5.png" alt="Ray-traced 3D rendering of the front side of the adapter board"><img height="250px" width="410px" src="../olimex-uext/olimex-uext-back-2024.5.png" alt="Ray-traced 3D rendering of the back side of the adapter board">
The [Olimex UEXT Adapter board](../olimex-uext) is designed to
be used with an [Olimex](https://www.olimex.com/Products/IoT/)
ESP32-based host board that has a
[UEXT](https://www.olimex.com/Products/Modules/) connector. However,
since the host connections are standard header pins, it can be used
with any ESP32-based board by using jumper wires.
The board contains the above-mentioned CN105-style connectors, the
analog multiplexers and MSP430, 3.3V-to-5V level shifters, and various
status LEDs.
## ESPHome Example Configuration
(coming soon)
## Credits
["Standing on the shoulders of
giants"](https://en.wikipedia.org/wiki/Standing_on_the_shoulders_of_giants)
could not be more true than it is in the open source hardware and
software community; this project relies on many wonderful tools and
libraries produced by the global open source hardware and software
community. I've listed many of them below, but if I've overlooked any
please do not be offended :-)
* [Fabrication Toolkit](https://github.com/bennymeg/Fabrication-Toolkit)
* [KiCAD](https://www.kicad.org/)
* [MSP430 GCC](https://www.ti.com/tool/MSP430-GCC-OPENSOURCE)
* [mspdebug](https://github.com/dlbeer/mspdebug)
* [opensource-toolchain-msp430](https://github.com/cjacker/opensource-toolchain-msp430)

View file

@ -0,0 +1,2 @@
[env]
CARGO_WORKSPACE_DIR = { value = "", relative = true }

1
firmware/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

1291
firmware/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

51
firmware/Cargo.toml Normal file
View file

@ -0,0 +1,51 @@
[workspace]
resolver = "3"
members = ["app", "boot"]
[workspace.dependencies]
embassy-boot = { version = "0.6.0" }
embassy-boot-stm32 = { version = "0.4.0" }
embassy-executor = { version = "0.8.0", features = ["arch-cortex-m", "executor-thread"] }
embassy-futures = { version = "0.1.1" }
embassy-stm32 = { version = "0.2.0", features = ["time-driver-tim1", "stm32g070kb", "unstable-pac" ] }
embassy-sync = { version = "0.7.0" }
embassy-time = { version = "0.4.0", features = ["tick-hz-32_768", "defmt-timestamp-uptime"] }
cortex-m = { version = "0.7.6", features = ["inline-asm", "critical-section-single-core"] }
cortex-m-rt = { version = "0.7.0" }
portable-atomic = { version = "1.5", features = ["unsafe-assume-single-core"] }
embedded-hal = { version = "1.0.0" }
embedded-io = { version = "0.6.1" }
embedded-io-async = { version = "0.6.1" }
embedded-resources = { version = "0.2.1", features = ["stm32"] }
embedded-storage = { version = "0.3.1" }
embedded-storage-async = { version = "0.4.1" }
byteorder = { version = "1.5.0", default-features = false }
static_cell = { version = "2.1.0" }
never = { version = "0.1.0", default-features = false }
thiserror = { version = "2.0.12", default-features = false }
defmt = { version = "1.0.0" }
defmt-rtt = { version = "1.0.0" }
panic-probe = { version = "1.0.0" }
[patch.crates-io]
embassy-boot = { git = "https://github.com/embassy-rs/embassy" }
embassy-boot-stm32 = { git = "https://github.com/embassy-rs/embassy" }
embassy-executor = { git = "https://github.com/embassy-rs/embassy" }
embassy-futures = { git = "https://github.com/embassy-rs/embassy" }
embassy-stm32 = { git = "https://github.com/embassy-rs/embassy" }
embassy-sync = { git = "https://github.com/embassy-rs/embassy" }
embassy-time = { git = "https://github.com/embassy-rs/embassy" }
[profile.dev]
opt-level = "s"
debug = 2
[profile.release]
codegen-units = 1
lto = "fat"
opt-level = "z"
debug = 2

View file

@ -0,0 +1,6 @@
[build]
target = "thumbv6m-none-eabi"
rustdocflags = ["--document-private-items"]
[env]
DEFMT_LOG = "trace"

50
firmware/app/Cargo.toml Normal file
View file

@ -0,0 +1,50 @@
[package]
name = "app"
version = "0.1.0"
edition = "2024"
[dependencies]
embassy-boot = { workspace = true, features = ["defmt"] }
embassy-boot-stm32 = { workspace = true, features = ["defmt"] }
embassy-executor = { workspace = true, features = ["defmt"] }
embassy-futures = { workspace = true, features = ["defmt"] }
embassy-stm32 = { workspace = true, features = ["defmt"] }
embassy-sync = { workspace = true }
embassy-time = { workspace = true, features = ["defmt"] }
cortex-m = { workspace = true }
cortex-m-rt = { workspace = true }
portable-atomic = { workspace = true }
embedded-hal = { workspace = true }
embedded-io = { workspace = true }
embedded-io-async = { workspace = true }
embedded-resources = { workspace = true }
byteorder = { workspace = true }
static_cell = { workspace = true }
never = { workspace = true }
thiserror = { workspace = true }
defmt = { workspace = true }
defmt-rtt = { workspace = true }
panic-probe = { workspace = true, features = ["print-defmt"] }
micropb = { version = "0.1.1", default-features = false, features = ["encode", "decode", "container-heapless"] }
[build-dependencies]
micropb-gen = "0.1.0"
[features]
default = ["board-nucleo64", "status-led"]
board-nucleo64 = []
status-led = []
copy-within = []
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
cargo = { level = "warn", priority = -1 }
wildcard_imports = "allow"
used_underscore_binding = "allow" # necessary because Embassy macros 'use' task arguments
cargo_common_metadata = "allow"
multiple_crate_versions = "allow" # necessary because the Embassy crates... yeah

62
firmware/app/build.rs Normal file
View file

@ -0,0 +1,62 @@
use std::{env, fs, path::Path};
use micropb_gen::Generator;
fn proto_generate(package_dir: &Path, project_dir: &Path) {
let proto_dir = Path::new(project_dir).join("proto");
let target_dir = package_dir.join("src");
let proto_files: Vec<_> = fs::read_dir(&proto_dir)
.unwrap()
.filter_map(Result::ok)
.filter(|de| {
if let Some(ext) = de.path().extension() {
ext == "proto"
} else {
false
}
})
.map(|de| de.file_name())
.collect();
let mut g = Generator::new();
g.use_container_heapless()
.add_protoc_arg(format!("-I{}", proto_dir.display()))
.compile_protos(&proto_files, target_dir.join("proto.rs"))
.unwrap();
println!("cargo:rerun-if-changed={}", proto_dir.display());
}
fn main() {
let workspace_dir = Path::new(env!("CARGO_WORKSPACE_DIR"));
let package_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let project_dir = workspace_dir.parent().unwrap();
// Make `all-memory.x` available to the linker.
// Tell Cargo where to find the file.
println!(
"cargo:rustc-link-search={}",
workspace_dir.join("common").display()
);
// Tell Cargo to rebuild if the file is updated.
println!(
"cargo:rerun-if-changed={}",
workspace_dir.join("common").join("all-memory.x").display()
);
// Make `memory.x` available to the linker.
// Tell Cargo where to find the file.
println!("cargo:rustc-link-search={}", package_dir.display());
// Tell Cargo to rebuild if the file is updated.
println!(
"cargo:rerun-if-changed={}",
package_dir.join("memory.x").display()
);
println!("cargo:rustc-link-arg-bins=--nmagic");
println!("cargo:rustc-link-arg-bins=-Tlink.x");
println!("cargo:rustc-link-arg-bins=-Tdefmt.x");
proto_generate(package_dir, project_dir);
}

3
firmware/app/memory.x Normal file
View file

@ -0,0 +1,3 @@
INCLUDE all-memory.x
REGION_ALIAS("FLASH", APP)

70
firmware/app/src/crc.rs Normal file
View file

@ -0,0 +1,70 @@
use defmt::*;
use embassy_stm32::crc;
use embassy_sync::blocking_mutex::raw::ThreadModeRawMutex;
use embassy_sync::mutex;
use static_cell::StaticCell;
type CrcEngine = mutex::Mutex<ThreadModeRawMutex, crc::Crc<'static>>;
static ENGINE: StaticCell<CrcEngine> = StaticCell::new();
#[derive(Clone, Copy)]
pub struct Handle {
engine: &'static CrcEngine,
}
pub struct Stream {
guard: mutex::MutexGuard<'static, ThreadModeRawMutex, crc::Crc<'static>>,
}
impl Handle {
pub fn new(r: crate::CrcEngineResources) -> Self {
let cfg = crc::Config::new(
crc::InputReverseConfig::None,
false,
crc::PolySize::Width16,
0x1d0f,
0x1021,
)
.unwrap();
let engine_ref = ENGINE.init(mutex::Mutex::new(crc::Crc::new(r.crc_engine, cfg)));
Handle { engine: engine_ref }
}
#[allow(clippy::cast_possible_truncation)]
pub async fn compute(&self, data: &[u8]) -> u16 {
let mut guard = self.engine.lock().await;
let engine = &mut *guard;
engine.reset();
trace!("CRC computed");
engine.feed_bytes(data) as u16
}
pub async fn stream(&self) -> Stream {
let mut guard = self.engine.lock().await;
let engine = &mut *guard;
trace!("CRC stream started");
engine.reset();
Stream { guard }
}
}
impl Stream {
pub fn feed_bytes(&mut self, data: &[u8]) {
let engine = &mut *self.guard;
engine.feed_bytes(data);
}
#[allow(clippy::cast_possible_truncation)]
pub fn result(&self) -> u16 {
let engine = &*self.guard;
trace!("CRC stream completed");
engine.read() as u16
}
}

View file

@ -0,0 +1,214 @@
use crate::{io, utils};
use defmt::*;
use embassy_time::Duration;
use embedded_io::{ReadReady, Write};
use embedded_io_async::Read;
use micropb::heapless::Vec;
use thiserror::Error;
pub trait Sender {
type Error;
fn send_sync(&mut self) -> Result<(), Self::Error>;
fn send_frame_fragment(&mut self, buf: &[u8]) -> Result<(), Self::Error>;
}
pub trait Receiver {
type Error;
fn buf(&self) -> &[u8];
fn clear_buf(&mut self);
fn remove_frame(&mut self, bytes: usize);
async fn receive_sync(&mut self) -> Result<(), Self::Error>;
async fn receive_frame_fragment(
&mut self,
fragment_pos: usize,
fragment_len: usize,
) -> Result<(), Self::Error>;
}
#[derive(Error)]
pub enum AsyncReadReceiverError<T>
where
T: Read + ReadReady,
{
#[error("insufficent buffer capacity")]
BufferCapacity,
#[error("timeout expired waiting for data")]
Timeout,
#[error("source error")]
Other(T::Error),
}
pub struct AsyncReadReceiver<const N: usize, T>
where
T: Read + ReadReady,
{
buf: &'static mut Vec<u8, N>,
rx: T,
sync: u8,
first_byte_timeout: Duration,
between_bytes_timeout: Duration,
}
impl<const N: usize, T> AsyncReadReceiver<N, T>
where
T: Read + ReadReady,
{
pub fn new(
buf: &'static mut Vec<u8, N>,
rx: T,
sync: u8,
first_byte_timeout: Duration,
between_bytes_timeout: Duration,
) -> Self {
Self {
buf,
rx,
sync,
first_byte_timeout,
between_bytes_timeout,
}
}
}
impl<const N: usize, T> Receiver for AsyncReadReceiver<N, T>
where
T: Read + ReadReady,
{
type Error = AsyncReadReceiverError<T>;
fn buf(&self) -> &[u8] {
self.buf.as_slice()
}
fn clear_buf(&mut self) {
self.buf.clear();
}
fn remove_frame(&mut self, bytes: usize) {
utils::remove_leading_bytes(self.buf, bytes);
}
async fn receive_sync(&mut self) -> Result<(), Self::Error> {
// look for a sync byte in the buffer; the buffer may have
// leftover bytes from a previous cycle (due to an incomplete
// message, or a message which failed validation)
if let Some(sync_pos) = self.buf.iter().position(|&x| x == self.sync) {
// if a sync byte was found, reset the buffer to the data
// following it
//
utils::remove_leading_bytes(self.buf, sync_pos + 1);
} else {
// remove any leftover data in the buffer, since a sync
// byte will begin a new frame
self.buf.clear();
// wait for a sync byte to arrive
let mut buf = [0_u8; 1];
loop {
debug!("waiting for sync byte");
match self.rx.read(&mut buf[..]).await {
Ok(_) => {
trace!("rx byte {=u8:#x}", buf[0]);
if buf[0] == self.sync {
break;
}
}
Err(e) => {
return Err(AsyncReadReceiverError::Other(e));
}
}
}
}
Ok(())
}
async fn receive_frame_fragment(
&mut self,
fragment_pos: usize,
fragment_len: usize,
) -> Result<(), Self::Error> {
// compute the number of bytes available in the buffer, and the
// number of bytes to read
let bytes_available = self.buf[fragment_pos..].len();
let bytes_to_read = fragment_len.saturating_sub(bytes_available);
// if there are enough bytes in the buffer, return
if bytes_to_read == 0 {
return Ok(());
}
// if the fragment will not fit in the buffer's available space,
// return an error
if bytes_to_read > (self.buf.capacity() - self.buf.len()) {
return Err(AsyncReadReceiverError::BufferCapacity);
}
// wait for the needed bytes to arrive, with timeouts
let read_start = fragment_pos + bytes_available;
let read_end = read_start + bytes_to_read;
// make temporary space in the buffer for the bytes to be read
unsafe {
self.buf.set_len(read_end);
}
match io::read_exact_with_timeouts(
&mut self.rx,
&mut self.buf[read_start..read_end],
self.first_byte_timeout,
self.between_bytes_timeout,
)
.await
{
Ok(()) => Ok(()),
Err(io::ReadError::Timeout { bytes_read }) => {
// set the buffer length to include the number of bytes
// that were actually read
unsafe {
self.buf.set_len(read_start + bytes_read);
}
Err(AsyncReadReceiverError::Timeout)
}
Err(io::ReadError::Other { bytes_read, source }) => {
// set the buffer length to include the number of bytes
// that were actually read
unsafe {
self.buf.set_len(read_start + bytes_read);
}
Err(AsyncReadReceiverError::Other(source))
}
}
}
}
pub struct WriteSender<T>
where
T: Write,
{
tx: T,
sync: u8,
}
impl<T> WriteSender<T>
where
T: Write,
{
pub fn new(tx: T, sync: u8) -> Self {
Self { tx, sync }
}
}
impl<T> Sender for WriteSender<T>
where
T: Write,
{
type Error = T::Error;
fn send_sync(&mut self) -> Result<(), Self::Error> {
let buf = [self.sync; 1];
self.tx.write_all(&buf)
}
fn send_frame_fragment(&mut self, buf: &[u8]) -> Result<(), Self::Error> {
self.tx.write_all(buf)
}
}

105
firmware/app/src/io/mod.rs Normal file
View file

@ -0,0 +1,105 @@
use embassy_futures::select::*;
use embassy_time::{Duration, Timer};
use embedded_io::ReadReady;
use embedded_io_async::Read;
use thiserror::Error;
pub mod framed;
pub mod proto;
#[derive(Error)]
pub enum ReadError<T>
where
T: Read + ReadReady,
{
#[error("timeout expired waiting for data")]
Timeout { bytes_read: usize },
#[error("source error")]
Other { bytes_read: usize, source: T::Error },
}
/// Reads data from an object which implements both
/// [`embedded_io_async::Read`] and [`embedded_io::ReadReady`], and
/// supports two timeouts:
///
/// `first_byte`: if no data is available immediately, will wait this
/// long for the first byte to arrive
///
/// `between_bytes`: after the first byte has been read, will wait
/// this long between bytes
///
/// Returns [`ReadError::Timeout`] if a timeout expires; the
/// `bytes_read` field of the error will contain the number of bytes
/// which had been read before the timeout occurred, if any
///
/// Returns [`ReadError::Other`] if an error occurs in the source
/// object; the `bytes_read` field of the error will contain the
/// number of bytes which had been read before the error occurred, if
/// any
///
/// Returns `Ok` if no error or timeout occurs
async fn read_exact_with_timeouts<T>(
rx: &mut T,
mut buf: &mut [u8],
first_byte: Duration,
between_bytes: Duration,
) -> Result<(), ReadError<T>>
where
T: Read + ReadReady,
{
let mut bytes_read = 0;
while !buf.is_empty() {
// check for immediately-available bytes, and read them
match rx.read_ready() {
Ok(false) => {}
Ok(true) => match rx.read(buf).await {
Ok(r) => {
buf = &mut buf[r..];
bytes_read += r;
continue;
}
Err(e) => {
return Err(ReadError::Other {
source: e,
bytes_read,
});
}
},
Err(e) => {
return Err(ReadError::Other {
source: e,
bytes_read,
});
}
}
// set a timeout based on whether the first byte has been read
let timeout = Timer::after(if bytes_read == 0 {
first_byte
} else {
between_bytes
});
// setup a single-byte read for the next byte; if more than
// one byte becomes available, the remainder will be read at
// the top of the next loop iteration
let src = rx.read(&mut buf[0..1]);
// wait for either the read or the timeout to complete
match select(src, timeout).await {
Either::First(Ok(r)) => {
buf = &mut buf[1..];
bytes_read += r;
}
Either::First(Err(e)) => {
return Err(ReadError::Other {
source: e,
bytes_read,
});
}
Either::Second(()) => {
return Err(ReadError::Timeout { bytes_read });
}
}
}
Ok(())
}

View file

@ -0,0 +1,143 @@
use byteorder::{ByteOrder, LittleEndian};
use defmt::*;
use micropb::{DecodeError, MessageDecode, MessageEncode, PbDecoder, PbEncoder, PbWrite};
use never;
use thiserror::Error;
use super::framed;
use crate::{crc, proto};
pub const SYNC_BYTE: u8 = 0xfc;
pub const MESSAGE_LENGTH_SIZE: usize = 2;
pub const MESSAGE_CRC_SIZE: usize = 2;
struct Writer<'a, T>
where
T: framed::Sender,
{
ftx: &'a mut T,
crc: &'a mut crc::Stream,
}
impl<T> PbWrite for Writer<'_, T>
where
T: framed::Sender,
{
type Error = T::Error;
fn pb_write(&mut self, data: &[u8]) -> Result<(), T::Error> {
self.ftx.send_frame_fragment(data)?;
self.crc.feed_bytes(data);
Ok(())
}
}
pub async fn send_target_message<T>(
ftx: &mut T,
msg: proto::api_::TargetMessage,
crc: crc::Handle,
) -> Result<(), T::Error>
where
T: framed::Sender,
{
debug!("sending sync");
ftx.send_sync()?;
debug!("message is {=u16} bytes", msg.compute_size() as u16);
#[allow(clippy::cast_possible_truncation)]
let encoded_size = (msg.compute_size() as u16).to_le_bytes();
ftx.send_frame_fragment(&encoded_size)?;
let mut crc_stream = crc.stream().await;
let mut writer = Writer {
ftx,
crc: &mut crc_stream,
};
let mut encoder = PbEncoder::new(&mut writer);
debug!("sending message");
msg.encode(&mut encoder)?;
let crc_result = crc_stream.result().to_le_bytes();
debug!("sending CRC");
ftx.send_frame_fragment(&crc_result)
}
#[derive(Error)]
pub enum ReceiveError<T>
where
T: framed::Receiver,
{
#[error("sync error")]
Sync,
#[error("CRC-16 mismatch")]
Crc,
#[error("framing error")]
Framing(T::Error),
#[error("decode error")]
Decode(DecodeError<never::Never>),
}
pub async fn receive_host_message<T>(
frx: &mut T,
crc: crc::Handle,
) -> Result<proto::api_::HostMessage, ReceiveError<T>>
where
T: framed::Receiver,
{
frx.receive_sync().await.or(Err(ReceiveError::Sync))?;
debug!("got sync");
// sync byte was seen, read the length of the message
frx.receive_frame_fragment(0, MESSAGE_LENGTH_SIZE)
.await
.map_err(ReceiveError::Framing)?;
debug!("got length");
// get the message length from the buffer
let message_len = LittleEndian::read_u16(&frx.buf()[..MESSAGE_LENGTH_SIZE]) as usize;
// read the message
let message_pos = MESSAGE_LENGTH_SIZE;
frx.receive_frame_fragment(message_pos, message_len)
.await
.map_err(ReceiveError::Framing)?;
debug!("got message");
// read the CRC-16 of the message
let crc_pos = message_pos + message_len;
frx.receive_frame_fragment(crc_pos, MESSAGE_CRC_SIZE)
.await
.map_err(ReceiveError::Framing)?;
debug!("got CRC");
// get the expected CRC-16 from the buffer
let expected_crc = LittleEndian::read_u16(&frx.buf()[crc_pos..crc_pos + MESSAGE_CRC_SIZE]);
// compute the actual CRC-16
let computed_crc = crc
.compute(&frx.buf()[message_pos..message_pos + message_len])
.await;
// check the CRC-16
if computed_crc != expected_crc {
return Err(ReceiveError::Crc);
}
debug!("CRC valid");
// at this point the buffer has been confirmed to contain a
// valid message frame, so any failures beyond this point can
// only drop the message, it cannot be re-parsed
let mut decoder = PbDecoder::new(&frx.buf()[message_pos..message_pos + message_len]);
let mut msg = proto::api_::HostMessage::default();
let result = msg
.decode(&mut decoder, message_len)
.map_err(ReceiveError::Decode)
.map(|_x| msg);
frx.remove_frame(crc_pos + MESSAGE_CRC_SIZE);
result
}

185
firmware/app/src/lib.rs Normal file
View file

@ -0,0 +1,185 @@
#![no_std]
extern crate core;
mod crc;
mod io;
mod tasks;
mod utils;
mod proto {
#![allow(clippy::all, clippy::pedantic)]
#![allow(nonstandard_style, unused, irrefutable_let_patterns)]
include!("proto.rs");
}
use core::mem;
use embassy_executor::Spawner;
use embassy_stm32::peripherals::*;
use embassy_stm32::{Peri, bind_interrupts, gpio, rcc, usart, wdg};
use embedded_resources::resource_group;
use tasks::*;
use defmt::*;
use defmt_rtt as _;
#[resource_group(no_aliases)]
struct SupervisorResources {
wdg: IWDG,
#[cfg(all(feature = "status-led", feature = "board-nucleo64"))]
status_led: PA5,
}
#[resource_group(no_aliases)]
struct CrcEngineResources {
crc_engine: CRC,
}
#[resource_group(no_aliases)]
struct HostInterfaceResources {
usart: USART1,
tx_pin: PA9,
rx_pin: PA10,
clr_host_transmit: PC14,
set_host_transmit: PC15,
host_active_led: PB7,
}
#[resource_group(no_aliases)]
struct HeatPumpInterfaceResources {
usart: USART4,
tx_pin: PA0,
rx_pin: PA1,
}
#[resource_group(no_aliases)]
struct ThermostatInterfaceResources {
usart: USART3,
tx_pin: PB2,
rx_pin: PB0,
}
#[resource_group(no_aliases)]
struct UnusedPinsResources {
pa2: PA2,
pa3: PA3,
pa4: PA4,
#[cfg(not(all(feature = "status-led", feature = "board-nucleo64")))]
pa5: PA5,
pa6: PA6,
pa7: PA7,
pa8: PA8,
pa11: PA11,
pa12: PA12,
pa15: PA15,
pb1: PB1,
pb3: PB3,
pb4: PB4,
pb5: PB5,
pb6: PB6,
pb8: PB8,
pb9: PB9,
pc6: PC6,
}
bind_interrupts!(struct Irqs {
USART1 => usart::BufferedInterruptHandler<USART1>;
USART3_4 => usart::BufferedInterruptHandler<USART3>, usart::BufferedInterruptHandler<USART4>;
});
pub fn spawn_tasks(spawner: Spawner) {
let p = embassy_stm32::init(build_rcc_config());
// enable Prefetch and Instruction Cache in FLASH peripheral
embassy_stm32::pac::FLASH.acr().modify(|w| {
w.set_prften(true);
w.set_icen(true);
});
disable_unused_peripherals();
configure_unused_pins(unused_pins_resources!(p));
let r = supervisor_resources!(p);
#[cfg(feature = "status-led")]
{
let led = gpio::Output::new(r.status_led, gpio::Level::Low, gpio::Speed::Low);
spawner.must_spawn(supervisor::status_task(led));
}
let wdg = wdg::IndependentWatchdog::new(r.wdg, 4_000_000);
spawner.must_spawn(supervisor::watchdog_task(wdg));
let crc_handle = crc::Handle::new(crc_engine_resources!(p));
host_interface::start(host_interface_resources!(p), spawner, crc_handle);
heat_pump_interface::start(heat_pump_interface_resources!(p), spawner);
thermostat_interface::start(thermostat_interface_resources!(p), spawner);
info!("startup complete");
}
fn configure_unused_pin(p: Peri<'_, impl gpio::Pin>) {
mem::forget(gpio::Input::new(p, gpio::Pull::Up));
}
fn configure_unused_pins(r: UnusedPinsResources) {
configure_unused_pin(r.pa2);
configure_unused_pin(r.pa3);
configure_unused_pin(r.pa4);
#[cfg(not(all(feature = "status-led", feature = "board-nucleo64")))]
configure_unused_pin(r.pa5);
configure_unused_pin(r.pa6);
configure_unused_pin(r.pa7);
configure_unused_pin(r.pa8);
configure_unused_pin(r.pa11);
configure_unused_pin(r.pa12);
configure_unused_pin(r.pa15);
configure_unused_pin(r.pb1);
configure_unused_pin(r.pb3);
configure_unused_pin(r.pb4);
configure_unused_pin(r.pb5);
configure_unused_pin(r.pb6);
configure_unused_pin(r.pb8);
configure_unused_pin(r.pb9);
configure_unused_pin(r.pc6);
}
fn build_rcc_config() -> embassy_stm32::Config {
let mut config = embassy_stm32::Config::default();
config.rcc.hsi = Some(rcc::Hsi {
sys_div: rcc::HsiSysDiv::DIV1,
});
config.rcc.pll = Some(rcc::Pll {
source: rcc::PllSource::HSI,
prediv: rcc::PllPreDiv::DIV1,
mul: rcc::PllMul::MUL16,
divp: None,
divq: Some(rcc::PllQDiv::DIV2), // 16 / 1 * 16 / 2 = 128 MHz
divr: Some(rcc::PllRDiv::DIV4), // 16 / 1 * 16 / 4 = 64 MHz
});
config.rcc.sys = rcc::Sysclk::PLL1_R;
config
}
fn disable_unused_peripherals() {
rcc::disable::<ADC1>();
rcc::disable::<I2C1>();
rcc::disable::<I2C2>();
rcc::disable::<I2C1>();
rcc::disable::<I2C2>();
rcc::disable::<SPI1>();
rcc::disable::<SPI2>();
rcc::disable::<TIM3>();
rcc::disable::<TIM6>();
rcc::disable::<TIM7>();
rcc::disable::<TIM14>();
rcc::disable::<TIM15>();
rcc::disable::<TIM16>();
rcc::disable::<TIM17>();
rcc::disable::<USART2>();
}

11
firmware/app/src/main.rs Normal file
View file

@ -0,0 +1,11 @@
#![no_std]
#![no_main]
use embassy_executor::Spawner;
use panic_probe as _;
#[embassy_executor::main]
async fn main(spawner: Spawner) {
app::spawn_tasks(spawner);
}

390
firmware/app/src/proto.rs Normal file
View file

@ -0,0 +1,390 @@
pub mod echo_ {
#[derive(Debug, Clone)]
pub struct EchoRequest {}
impl ::core::default::Default for EchoRequest {
fn default() -> Self {
Self {}
}
}
impl ::core::cmp::PartialEq for EchoRequest {
fn eq(&self, other: &Self) -> bool {
let mut ret = true;
ret
}
}
impl EchoRequest {}
impl ::micropb::MessageDecode for EchoRequest {
fn decode<IMPL_MICROPB_READ: ::micropb::PbRead>(
&mut self,
decoder: &mut ::micropb::PbDecoder<IMPL_MICROPB_READ>,
len: usize,
) -> Result<(), ::micropb::DecodeError<IMPL_MICROPB_READ::Error>> {
use ::micropb::{PbVec, PbMap, PbString, FieldDecode};
let before = decoder.bytes_read();
while decoder.bytes_read() - before < len {
let tag = decoder.decode_tag()?;
match tag.field_num() {
0 => return Err(::micropb::DecodeError::ZeroField),
_ => {
decoder.skip_wire_value(tag.wire_type())?;
}
}
}
Ok(())
}
}
impl ::micropb::MessageEncode for EchoRequest {
fn encode<IMPL_MICROPB_WRITE: ::micropb::PbWrite>(
&self,
encoder: &mut ::micropb::PbEncoder<IMPL_MICROPB_WRITE>,
) -> Result<(), IMPL_MICROPB_WRITE::Error> {
use ::micropb::{PbVec, PbMap, PbString, FieldEncode};
Ok(())
}
fn compute_size(&self) -> usize {
use ::micropb::{PbVec, PbMap, PbString, FieldEncode};
let mut size = 0;
size
}
}
#[derive(Debug, Clone)]
pub struct EchoResponse {}
impl ::core::default::Default for EchoResponse {
fn default() -> Self {
Self {}
}
}
impl ::core::cmp::PartialEq for EchoResponse {
fn eq(&self, other: &Self) -> bool {
let mut ret = true;
ret
}
}
impl EchoResponse {}
impl ::micropb::MessageDecode for EchoResponse {
fn decode<IMPL_MICROPB_READ: ::micropb::PbRead>(
&mut self,
decoder: &mut ::micropb::PbDecoder<IMPL_MICROPB_READ>,
len: usize,
) -> Result<(), ::micropb::DecodeError<IMPL_MICROPB_READ::Error>> {
use ::micropb::{PbVec, PbMap, PbString, FieldDecode};
let before = decoder.bytes_read();
while decoder.bytes_read() - before < len {
let tag = decoder.decode_tag()?;
match tag.field_num() {
0 => return Err(::micropb::DecodeError::ZeroField),
_ => {
decoder.skip_wire_value(tag.wire_type())?;
}
}
}
Ok(())
}
}
impl ::micropb::MessageEncode for EchoResponse {
fn encode<IMPL_MICROPB_WRITE: ::micropb::PbWrite>(
&self,
encoder: &mut ::micropb::PbEncoder<IMPL_MICROPB_WRITE>,
) -> Result<(), IMPL_MICROPB_WRITE::Error> {
use ::micropb::{PbVec, PbMap, PbString, FieldEncode};
Ok(())
}
fn compute_size(&self) -> usize {
use ::micropb::{PbVec, PbMap, PbString, FieldEncode};
let mut size = 0;
size
}
}
}
pub mod api_ {
pub mod HostMessage_ {
#[derive(Debug, PartialEq, Clone)]
pub enum Msg {
Echo(super::super::echo_::EchoRequest),
}
}
#[derive(Debug, Clone)]
pub struct HostMessage {
pub r#id: u32,
pub r#msg: ::core::option::Option<HostMessage_::Msg>,
}
impl ::core::default::Default for HostMessage {
fn default() -> Self {
Self {
r#id: ::core::default::Default::default(),
r#msg: ::core::default::Default::default(),
}
}
}
impl ::core::cmp::PartialEq for HostMessage {
fn eq(&self, other: &Self) -> bool {
let mut ret = true;
ret &= (self.r#id == other.r#id);
ret &= (self.r#msg == other.r#msg);
ret
}
}
impl HostMessage {
///Return a reference to `id`
#[inline]
pub fn r#id(&self) -> &u32 {
&self.r#id
}
///Return a mutable reference to `id`
#[inline]
pub fn mut_id(&mut self) -> &mut u32 {
&mut self.r#id
}
///Set the value of `id`
#[inline]
pub fn set_id(&mut self, value: u32) -> &mut Self {
self.r#id = value.into();
self
}
///Builder method that sets the value of `id`. Useful for initializing the message.
#[inline]
pub fn init_id(mut self, value: u32) -> Self {
self.r#id = value.into();
self
}
}
impl ::micropb::MessageDecode for HostMessage {
fn decode<IMPL_MICROPB_READ: ::micropb::PbRead>(
&mut self,
decoder: &mut ::micropb::PbDecoder<IMPL_MICROPB_READ>,
len: usize,
) -> Result<(), ::micropb::DecodeError<IMPL_MICROPB_READ::Error>> {
use ::micropb::{PbVec, PbMap, PbString, FieldDecode};
let before = decoder.bytes_read();
while decoder.bytes_read() - before < len {
let tag = decoder.decode_tag()?;
match tag.field_num() {
0 => return Err(::micropb::DecodeError::ZeroField),
1u32 => {
let mut_ref = &mut self.r#id;
{
let val = decoder.decode_varint32()?;
let val_ref = &val;
if *val_ref != 0 {
*mut_ref = val as _;
}
};
}
2u32 => {
let mut_ref = loop {
if let ::core::option::Option::Some(variant) = &mut self
.r#msg
{
if let HostMessage_::Msg::Echo(variant) = &mut *variant {
break &mut *variant;
}
}
self.r#msg = ::core::option::Option::Some(
HostMessage_::Msg::Echo(::core::default::Default::default()),
);
};
mut_ref.decode_len_delimited(decoder)?;
}
_ => {
decoder.skip_wire_value(tag.wire_type())?;
}
}
}
Ok(())
}
}
impl ::micropb::MessageEncode for HostMessage {
fn encode<IMPL_MICROPB_WRITE: ::micropb::PbWrite>(
&self,
encoder: &mut ::micropb::PbEncoder<IMPL_MICROPB_WRITE>,
) -> Result<(), IMPL_MICROPB_WRITE::Error> {
use ::micropb::{PbVec, PbMap, PbString, FieldEncode};
{
let val_ref = &self.r#id;
if *val_ref != 0 {
encoder.encode_varint32(8u32)?;
encoder.encode_varint32(*val_ref as _)?;
}
}
if let Some(oneof) = &self.r#msg {
match &*oneof {
HostMessage_::Msg::Echo(val_ref) => {
let val_ref = &*val_ref;
encoder.encode_varint32(18u32)?;
val_ref.encode_len_delimited(encoder)?;
}
}
}
Ok(())
}
fn compute_size(&self) -> usize {
use ::micropb::{PbVec, PbMap, PbString, FieldEncode};
let mut size = 0;
{
let val_ref = &self.r#id;
if *val_ref != 0 {
size += 1usize + ::micropb::size::sizeof_varint32(*val_ref as _);
}
}
if let Some(oneof) = &self.r#msg {
match &*oneof {
HostMessage_::Msg::Echo(val_ref) => {
let val_ref = &*val_ref;
size
+= 1usize
+ ::micropb::size::sizeof_len_record(
val_ref.compute_size(),
);
}
}
}
size
}
}
pub mod TargetMessage_ {
#[derive(Debug, PartialEq, Clone)]
pub enum Msg {
Echo(super::super::echo_::EchoResponse),
}
}
#[derive(Debug, Clone)]
pub struct TargetMessage {
pub r#id: u32,
pub r#msg: ::core::option::Option<TargetMessage_::Msg>,
}
impl ::core::default::Default for TargetMessage {
fn default() -> Self {
Self {
r#id: ::core::default::Default::default(),
r#msg: ::core::default::Default::default(),
}
}
}
impl ::core::cmp::PartialEq for TargetMessage {
fn eq(&self, other: &Self) -> bool {
let mut ret = true;
ret &= (self.r#id == other.r#id);
ret &= (self.r#msg == other.r#msg);
ret
}
}
impl TargetMessage {
///Return a reference to `id`
#[inline]
pub fn r#id(&self) -> &u32 {
&self.r#id
}
///Return a mutable reference to `id`
#[inline]
pub fn mut_id(&mut self) -> &mut u32 {
&mut self.r#id
}
///Set the value of `id`
#[inline]
pub fn set_id(&mut self, value: u32) -> &mut Self {
self.r#id = value.into();
self
}
///Builder method that sets the value of `id`. Useful for initializing the message.
#[inline]
pub fn init_id(mut self, value: u32) -> Self {
self.r#id = value.into();
self
}
}
impl ::micropb::MessageDecode for TargetMessage {
fn decode<IMPL_MICROPB_READ: ::micropb::PbRead>(
&mut self,
decoder: &mut ::micropb::PbDecoder<IMPL_MICROPB_READ>,
len: usize,
) -> Result<(), ::micropb::DecodeError<IMPL_MICROPB_READ::Error>> {
use ::micropb::{PbVec, PbMap, PbString, FieldDecode};
let before = decoder.bytes_read();
while decoder.bytes_read() - before < len {
let tag = decoder.decode_tag()?;
match tag.field_num() {
0 => return Err(::micropb::DecodeError::ZeroField),
1u32 => {
let mut_ref = &mut self.r#id;
{
let val = decoder.decode_varint32()?;
let val_ref = &val;
if *val_ref != 0 {
*mut_ref = val as _;
}
};
}
2u32 => {
let mut_ref = loop {
if let ::core::option::Option::Some(variant) = &mut self
.r#msg
{
if let TargetMessage_::Msg::Echo(variant) = &mut *variant {
break &mut *variant;
}
}
self.r#msg = ::core::option::Option::Some(
TargetMessage_::Msg::Echo(
::core::default::Default::default(),
),
);
};
mut_ref.decode_len_delimited(decoder)?;
}
_ => {
decoder.skip_wire_value(tag.wire_type())?;
}
}
}
Ok(())
}
}
impl ::micropb::MessageEncode for TargetMessage {
fn encode<IMPL_MICROPB_WRITE: ::micropb::PbWrite>(
&self,
encoder: &mut ::micropb::PbEncoder<IMPL_MICROPB_WRITE>,
) -> Result<(), IMPL_MICROPB_WRITE::Error> {
use ::micropb::{PbVec, PbMap, PbString, FieldEncode};
{
let val_ref = &self.r#id;
if *val_ref != 0 {
encoder.encode_varint32(8u32)?;
encoder.encode_varint32(*val_ref as _)?;
}
}
if let Some(oneof) = &self.r#msg {
match &*oneof {
TargetMessage_::Msg::Echo(val_ref) => {
let val_ref = &*val_ref;
encoder.encode_varint32(18u32)?;
val_ref.encode_len_delimited(encoder)?;
}
}
}
Ok(())
}
fn compute_size(&self) -> usize {
use ::micropb::{PbVec, PbMap, PbString, FieldEncode};
let mut size = 0;
{
let val_ref = &self.r#id;
if *val_ref != 0 {
size += 1usize + ::micropb::size::sizeof_varint32(*val_ref as _);
}
}
if let Some(oneof) = &self.r#msg {
match &*oneof {
TargetMessage_::Msg::Echo(val_ref) => {
let val_ref = &*val_ref;
size
+= 1usize
+ ::micropb::size::sizeof_len_record(
val_ref.compute_size(),
);
}
}
}
size
}
}
}

View file

@ -0,0 +1,4 @@
pub mod heat_pump_interface;
pub mod host_interface;
pub mod supervisor;
pub mod thermostat_interface;

View file

@ -0,0 +1,57 @@
use embassy_executor::Spawner;
use embassy_stm32::usart;
use static_cell::StaticCell;
struct UartBuf {
buffer: [u8; 32],
}
impl Default for UartBuf {
fn default() -> UartBuf {
unsafe { core::mem::zeroed() }
}
}
static UART_TX_BUF: StaticCell<UartBuf> = StaticCell::new();
static UART_RX_BUF: StaticCell<UartBuf> = StaticCell::new();
#[allow(dead_code)]
struct Tx {
uart: usart::BufferedUartTx<'static>,
}
#[allow(dead_code)]
struct Rx {
uart: usart::BufferedUartRx<'static>,
}
pub fn start(r: crate::HeatPumpInterfaceResources, spawner: Spawner) {
let tx_buf = UART_TX_BUF.init(UartBuf::default());
let rx_buf = UART_RX_BUF.init(UartBuf::default());
let mut config = usart::Config::default();
config.baudrate = 2400;
config.parity = usart::Parity::ParityEven;
config.tx_config = usart::OutputConfig::OpenDrain;
let (uart_tx, uart_rx) = usart::BufferedUart::new(
r.usart,
r.rx_pin,
r.tx_pin,
&mut tx_buf.buffer,
&mut rx_buf.buffer,
crate::Irqs,
config,
)
.unwrap()
.split();
spawner.must_spawn(heat_pump_tx_task(Tx { uart: uart_tx }));
spawner.must_spawn(heat_pump_rx_task(Rx { uart: uart_rx }));
}
#[embassy_executor::task]
async fn heat_pump_tx_task(_tx: Tx) {}
#[embassy_executor::task]
async fn heat_pump_rx_task(_rx: Rx) {}

View file

@ -0,0 +1,197 @@
use crate::io::framed::Receiver;
use crate::{crc, io, proto};
use defmt::*;
use embassy_executor::Spawner;
use embassy_stm32::{gpio, usart};
use embassy_sync::blocking_mutex::raw::ThreadModeRawMutex;
use embassy_sync::channel;
use embassy_time::Duration;
use static_cell::StaticCell;
use micropb::{DecodeError, heapless::Vec};
use crate::proto::api_::*;
use crate::proto::echo_::*;
const MAX_HOST_MESSAGE_SIZE: usize =
io::proto::MESSAGE_LENGTH_SIZE + io::proto::MESSAGE_CRC_SIZE + 2048;
struct UartBuf {
buffer: [u8; 128],
}
impl Default for UartBuf {
fn default() -> UartBuf {
unsafe { core::mem::zeroed() }
}
}
static UART_TX_BUF: StaticCell<UartBuf> = StaticCell::new();
static UART_RX_BUF: StaticCell<UartBuf> = StaticCell::new();
type MessageBuf = Vec<u8, MAX_HOST_MESSAGE_SIZE>;
static MESSAGE_BUF: StaticCell<MessageBuf> = StaticCell::new();
type TargetMessageChannel = channel::Channel<ThreadModeRawMutex, proto::api_::TargetMessage, 3>;
static TARGET_MESSAGE_CHANNEL: TargetMessageChannel = channel::Channel::new();
struct Tx {
uart: usart::BufferedUartTx<'static>,
crc: crc::Handle,
}
#[allow(dead_code)]
struct Rx {
message_buf: &'static mut MessageBuf,
uart: usart::BufferedUartRx<'static>,
crc: crc::Handle,
clr_host_transmit: gpio::OutputOpenDrain<'static>,
set_host_transmit: gpio::OutputOpenDrain<'static>,
host_active_led: gpio::OutputOpenDrain<'static>,
}
pub fn start(r: crate::HostInterfaceResources, spawner: Spawner, crc: crc::Handle) {
let tx_buf = UART_TX_BUF.init(UartBuf::default());
let rx_buf = UART_RX_BUF.init(UartBuf::default());
let message_buf = MESSAGE_BUF.init(MessageBuf::new());
let mut config = usart::Config::default();
config.baudrate = 38400;
let (uart_tx, uart_rx) = usart::BufferedUart::new(
r.usart,
r.rx_pin,
r.tx_pin,
&mut tx_buf.buffer,
&mut rx_buf.buffer,
crate::Irqs,
config,
)
.unwrap()
.split();
let clr_host_transmit =
gpio::OutputOpenDrain::new(r.clr_host_transmit, gpio::Level::High, gpio::Speed::Low);
let set_host_transmit =
gpio::OutputOpenDrain::new(r.set_host_transmit, gpio::Level::High, gpio::Speed::Low);
let host_active_led =
gpio::OutputOpenDrain::new(r.host_active_led, gpio::Level::High, gpio::Speed::Low);
spawner.must_spawn(host_tx_task(Tx { uart: uart_tx, crc }));
spawner.must_spawn(host_rx_task(Rx {
message_buf,
uart: uart_rx,
crc,
clr_host_transmit,
set_host_transmit,
host_active_led,
}));
}
#[embassy_executor::task]
async fn host_tx_task(tx: Tx) {
let mut ftx = io::framed::WriteSender::new(tx.uart, io::proto::SYNC_BYTE);
let receiver = TARGET_MESSAGE_CHANNEL.receiver();
loop {
let msg = receiver.receive().await;
debug!("sending message");
let _ = io::proto::send_target_message(&mut ftx, msg, tx.crc).await;
}
}
#[embassy_executor::task]
async fn host_rx_task(rx: Rx) {
let mut frx = io::framed::AsyncReadReceiver::new(
rx.message_buf,
rx.uart,
io::proto::SYNC_BYTE,
Duration::from_millis(250),
Duration::from_millis(50),
);
loop {
match io::proto::receive_host_message(&mut frx, rx.crc).await {
Ok(msg) => {
debug!("message received");
handle_host_message(msg).await;
}
Err(io::proto::ReceiveError::Sync) => {
error!("sync error");
}
Err(io::proto::ReceiveError::Crc) => {
error!("CRC error");
}
Err(io::proto::ReceiveError::Framing(e)) => {
match e {
io::framed::AsyncReadReceiverError::BufferCapacity => {
// the host message will not fit in the
// buffer, so clear the buffer and restart the
// loop
error!("message too large");
frx.clear_buf();
}
io::framed::AsyncReadReceiverError::Timeout => {
// restart loop, since the sync byte seen wasn't
// the beginning of a message, or the sender
// stopped sending
error!("timeout");
}
io::framed::AsyncReadReceiverError::Other(_) => {
// restart loop when a USART error occurs
error!("USART error");
}
}
}
Err(io::proto::ReceiveError::Decode(e)) => match e {
DecodeError::VarIntLimit => {
error!("decoder: VarInt too long");
}
DecodeError::UnexpectedEof => {
error!("decoder: unexpected EOF");
}
DecodeError::Deprecation => {
error!("decoder: deprecated wire type found");
}
DecodeError::UnknownWireType => {
error!("decoder: unknown wire type found");
}
DecodeError::ZeroField => {
error!("decoder: invalid field number 0");
}
DecodeError::Utf8 => {
error!("decoder: invalid UTF-8 string");
}
DecodeError::Capacity => {
error!("decoder: capacity of fixed-length field exceeed");
}
DecodeError::WrongLen => {
error!("decoder: length-delimited-record too long");
}
_ => {
error!("decoder: unknown");
}
},
}
}
}
async fn handle_host_message(host: HostMessage) {
match host.msg {
None => {
error!("empty message");
}
Some(HostMessage_::Msg::Echo(_)) => {
info!("echo request");
TARGET_MESSAGE_CHANNEL
.sender()
.send(TargetMessage {
id: host.id,
msg: Some(TargetMessage_::Msg::Echo(EchoResponse {})),
})
.await;
}
}
}

View file

@ -0,0 +1,30 @@
use defmt::*;
#[cfg(feature = "status-led")]
use embassy_stm32::gpio;
use embassy_stm32::{peripherals, wdg};
#[cfg(feature = "status-led")]
use embassy_time::Timer;
use embassy_time::{Duration, Ticker};
#[cfg(feature = "status-led")]
#[embassy_executor::task]
pub async fn status_task(mut led: gpio::Output<'static>) {
let mut ticker = Ticker::every(Duration::from_millis(1500));
loop {
ticker.next().await;
led.toggle();
Timer::after_millis(100).await;
led.toggle();
}
}
#[embassy_executor::task]
pub async fn watchdog_task(mut wdg: wdg::IndependentWatchdog<'static, peripherals::IWDG>) {
let mut ticker = Ticker::every(Duration::from_secs(1));
wdg.unleash();
loop {
ticker.next().await;
wdg.pet();
trace!("watchdog");
}
}

View file

@ -0,0 +1,57 @@
use embassy_executor::Spawner;
use embassy_stm32::usart;
use static_cell::StaticCell;
struct UartBuf {
buffer: [u8; 32],
}
impl Default for UartBuf {
fn default() -> UartBuf {
unsafe { core::mem::zeroed() }
}
}
static UART_TX_BUF: StaticCell<UartBuf> = StaticCell::new();
static UART_RX_BUF: StaticCell<UartBuf> = StaticCell::new();
#[allow(dead_code)]
struct Tx {
uart: usart::BufferedUartTx<'static>,
}
#[allow(dead_code)]
struct Rx {
uart: usart::BufferedUartRx<'static>,
}
pub fn start(r: crate::ThermostatInterfaceResources, spawner: Spawner) {
let tx_buf = UART_TX_BUF.init(UartBuf::default());
let rx_buf = UART_RX_BUF.init(UartBuf::default());
let mut config = usart::Config::default();
config.baudrate = 2400;
config.parity = usart::Parity::ParityEven;
config.tx_config = usart::OutputConfig::OpenDrain;
let (uart_tx, uart_rx) = usart::BufferedUart::new(
r.usart,
r.rx_pin,
r.tx_pin,
&mut tx_buf.buffer,
&mut rx_buf.buffer,
crate::Irqs,
config,
)
.unwrap()
.split();
spawner.must_spawn(thermostat_tx_task(Tx { uart: uart_tx }));
spawner.must_spawn(thermostat_rx_task(Rx { uart: uart_rx }));
}
#[embassy_executor::task]
async fn thermostat_tx_task(_tx: Tx) {}
#[embassy_executor::task]
async fn thermostat_rx_task(_rx: Rx) {}

12
firmware/app/src/utils.rs Normal file
View file

@ -0,0 +1,12 @@
use micropb::heapless::Vec;
pub fn remove_leading_bytes<const N: usize>(buf: &mut Vec<u8, N>, remove: usize) {
let retain_bytes = buf.len() - remove;
#[cfg(not(feature = "copy-within"))]
for i in 0..retain_bytes {
buf[i] = buf[remove + i];
}
#[cfg(feature = "copy-within")]
buf.copy_within(remove.., 0);
buf.truncate(retain_bytes);
}

View file

@ -0,0 +1,3 @@
[build]
target = "thumbv6m-none-eabi"
rustdocflags = ["--document-private-items"]

26
firmware/boot/Cargo.toml Normal file
View file

@ -0,0 +1,26 @@
[package]
name = "boot"
version = "0.1.0"
edition = "2024"
[dependencies]
embassy-boot = { workspace = true }
embassy-boot-stm32 = { workspace = true }
embassy-executor = { workspace = true }
embassy-stm32 = { workspace = true }
embassy-sync = { workspace = true }
cortex-m = { workspace = true }
cortex-m-rt = { workspace = true }
embedded-storage = { workspace = true }
embedded-storage-async = { workspace = true }
cfg-if = "1.0.1"
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
cargo = { level = "warn", priority = -1 }
wildcard_imports = "allow"
cargo_common_metadata = "allow"
multiple_crate_versions = "allow" # necessary because the Embassy crates... yeah

30
firmware/boot/build.rs Normal file
View file

@ -0,0 +1,30 @@
use std::{env, path::Path};
fn main() {
let workspace_dir = Path::new(env!("CARGO_WORKSPACE_DIR"));
let package_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
// Make `all-memory.x` available to the linker.
// Tell Cargo where to find the file.
println!(
"cargo:rustc-link-search={}",
workspace_dir.join("common").display()
);
// Tell Cargo to rebuild if the file is updated.
println!(
"cargo:rerun-if-changed={}",
workspace_dir.join("common").join("all-memory.x").display()
);
// Make `memory.x` available to the linker.
// Tell Cargo where to find the file.
println!("cargo:rustc-link-search={}", package_dir.display());
// Tell Cargo to rebuild if the file is updated.
println!(
"cargo:rerun-if-changed={}",
package_dir.join("memory.x").display()
);
println!("cargo:rustc-link-arg-bins=--nmagic");
println!("cargo:rustc-link-arg-bins=-Tlink.x");
}

3
firmware/boot/memory.x Normal file
View file

@ -0,0 +1,3 @@
INCLUDE all-memory.x
REGION_ALIAS("FLASH", BOOT)

51
firmware/boot/src/main.rs Normal file
View file

@ -0,0 +1,51 @@
#![no_std]
#![no_main]
use core::cell::RefCell;
use cortex_m_rt::{entry, exception};
use embassy_boot_stm32::*;
use embassy_stm32::Config;
use embassy_stm32::flash::{BANK1_REGION, Flash};
use embassy_sync::blocking_mutex::Mutex;
#[entry]
fn main() -> ! {
let p = embassy_stm32::init(Config::default());
// Uncomment this if you are debugging the bootloader with debugger/RTT attached,
// as it prevents a hard fault when accessing flash 'too early' after boot.
// for _i in 0..50_000_000 {
// cortex_m::asm::nop();
// }
let layout = Flash::new_blocking(p.FLASH).into_blocking_regions();
let flash = Mutex::new(RefCell::new(layout.bank1_region));
let config = BootLoaderConfig::from_linkerfile_blocking(&flash, &flash, &flash);
let active_offset = config.active.offset();
let bl = BootLoader::prepare::<_, _, _, 2048>(config);
unsafe { bl.load(BANK1_REGION.base + active_offset) }
}
#[unsafe(no_mangle)]
#[cfg_attr(target_os = "none", unsafe(link_section = ".HardFault.user"))]
unsafe extern "C" fn HardFault() {
cortex_m::peripheral::SCB::sys_reset();
}
#[exception]
unsafe fn DefaultHandler(_: i16) -> ! {
const SCB_ICSR: *const u32 = 0xE000_ED04 as *const u32;
unsafe {
#[allow(clippy::cast_possible_truncation)]
let irqn = i16::from(core::ptr::read_volatile(SCB_ICSR) as u8) - 16;
panic!("DefaultHandler #{:?}", irqn);
}
}
#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
cortex_m::asm::udf();
}

View file

@ -0,0 +1,18 @@
MEMORY
{
FLASH_BASE : ORIGIN = 0x08000000, LENGTH = 0
BOOT : ORIGIN = ORIGIN(FLASH_BASE), LENGTH = 16K
BOOT_STATE : ORIGIN = ORIGIN(BOOT) + LENGTH(BOOT), LENGTH = 4K
APP : ORIGIN = ORIGIN(BOOT_STATE) + LENGTH(BOOT_STATE), LENGTH = 52K
DFU : ORIGIN = ORIGIN(APP) + LENGTH(APP), LENGTH = 56K
RAM : ORIGIN = 0x20000000, LENGTH = 36K
}
__bootloader_state_start = ORIGIN(BOOT_STATE) - ORIGIN(FLASH_BASE);
__bootloader_state_end = ORIGIN(BOOT_STATE) + LENGTH(BOOT_STATE) - ORIGIN(FLASH_BASE);
__bootloader_active_start = ORIGIN(APP) - ORIGIN(FLASH_BASE);
__bootloader_active_end = ORIGIN(APP) + LENGTH(APP) - ORIGIN(FLASH_BASE);
__bootloader_dfu_start = ORIGIN(DFU) - ORIGIN(FLASH_BASE);
__bootloader_dfu_end = ORIGIN(DFU) + LENGTH(DFU) - ORIGIN(FLASH_BASE);

1
firmware/run Symbolic link
View file

@ -0,0 +1 @@
run.sh

70
firmware/run.sh Executable file
View file

@ -0,0 +1,70 @@
#!/usr/bin/env bash
CHIP=STM32G070KB
function clean {
cargo clean $@
}
function build-boot {
(cd boot; cargo build --release $@)
}
function build-app {
(cd app; cargo build --release $@)
}
function build {
build-boot
build-app
}
function size-boot {
(cd boot; cargo size --release -- -A $@)
}
function size-app {
(cd app; cargo size --release -- -A $@)
}
function size {
size-boot
size-app
}
function erase {
probe-rs erase --chip ${CHIP}
}
function flash-boot {
(cd boot; cargo flash --release --chip ${CHIP} --reset-halt --log INFO $@)
}
function flash-app {
(cd app; cargo flash --release --chip ${CHIP} --reset-halt --log INFO $@)
}
function flash {
flash-boot
flash-app
}
function run {
probe-rs attach --chip ${CHIP} --log-format "{t} {m} {s}" target/thumbv6m-none-eabi/release/app
}
set -o nounset
set -o pipefail
set -o errexit
#set -o xtrace
PROJECT_ROOT=${0%/*}
if [[ $0 != $PROJECT_ROOT && $PROJECT_ROOT != "" ]]; then
cd "$PROJECT_ROOT"
fi
readonly PROJECT_ROOT=$(pwd)
readonly SCRIPT="$PROJECT_ROOT/$(basename "$0")"
TIMEFORMAT=$'\nTask completed in %3lR'
time "${@}"

View file

@ -0,0 +1,5 @@
[toolchain]
channel = "stable"
profile = "default"
components = [ "llvm-tools" ]
targets = [ "thumbv6m-none-eabi" ]

2
firmware/rustfmt.toml Normal file
View file

@ -0,0 +1,2 @@
edition = "2024"
style_edition = "2024"

83
hw-notes.md Normal file
View file

@ -0,0 +1,83 @@
UART1
TX - PA9 (pin 19 - FT_fd), PB6 (pin 30 - FT_fa)
RX - PA10 (pin 21 - FT_fd), PB7 (pin 31 - FT_fa)
UART2
on Nucleo64 board this UART is connected to the ST-LINK interface
TX - PA2 (pin 9 - FT_a), PA14-BOOT0 (pin 25 - FT)
RX - PA3 (pin 10 - FT_a), PA15 (pin 26 - FT)
UART3
TX - PA5 (pin 12 - TT_a), PB2 (pin 17 - FT_a), PB8 (pin 32 - FT_f)
RX - PB0 (pin 15 - FT_a), PB9 (pin 1 - FT_f)
UART4
TX - PA0 (pin 7 - FT_a)
RX - PA1 (pin 8 - FT_a)
All UART pins are FT except for PA5, so 5V-to-3V3 level translators will not be required.
UART assignments
Host - UART1 on PA9/PA10 (allows use of embedded boot loader)
HP - UART4 on PA0/PA1
TH - UART3 on PB2/PB0
# Pin Usage
|Pin|Function|Name|Configuration/Connection|
|:---:|:---:|:---:|:---|
|01|PB9|unused|digital input with internal pull-up|
|02|PC14|CLR\_HOST\_TRANSMIT|open-drain output with external pull-up to 5V, reset combiner AND gate|
|03|PC15|SET\_HOST\_TRANSMIT|open-drain output with external pull-up to 5V, S input of S/R latch|
|04|VDD|||
|05|VSS|||
|06|NRST||internal pull-up, SWD header and host|
|07|PA0 - USART4\_TX|MCU\_TO\_HP|open-drain output with external pull-up to 5V, 4-channel UART buffer|
|08|PA1 - USART4\_RX|FROM\_HP|digital input, HP CN105 connector|
|09|PA2|unused|digital input with internal pull-up|
|10|PA3|unused|digital input with internal pull-up|
|11|PA4|unused|digital input with internal pull-up|
|12|PA5|STATUS\_LED|output, LED on Nucleo64|
|13|PA6|unused|digital input with internal pull-up|
|14|PA7|unused|digital input with internal pull-up|
|15|PB0 - USART3\_RX|FROM\_TH|digital input, TH CN105 connector|
|16|PB1|unused|digital input with internal pull-up|
|17|PB2 - USART3\_TX|MCU\_TO\_TH|open-drain output with external pull-up to 5V, 4-channel UART buffer|
|18|PA8|unused|digital input with internal pull-up|
|19|PA9 - USART1\_TX|MCU\_TO\_HOST|digital output, host connector|
|20|PC6|unused|digital input with internal pull-up|
|21|PA10 - USART1\_RX|HOST\_TO\_MCU|digital input, host connector|
|22|PA11|unused|digital input with internal pull-up|
|23|PA12|unused|digital input with internal pull-up|
|24|PA13 - SWDIO|SWDIO|SWD header|
|25|PA14 - BOOT0|SWCLK-BOOT0|SWD header and host|
|26|PA15|unused|digital input with internal pull-up|
|27|PB3|unused|digital input with internal pull-up|
|28|PB4|unused|digital input with internal pull-up|
|29|PB5|unused|digital input with internal pull-up|
|30|PB6|unused|digital input with internal pull-up|
|31|PB7|HOST\_ACTIVE|open-drain output|
|32|PB8|unused|digital input with internal pull-up|
# Power Budget
All estimates using worst-case conditions from datasheets.
|Device|5V current (μA)|3V3 current (μA)|Notes|
|---:|---:|---:|:---:|
|STM32G070KB||9,400|Computed using STM32CubeMX|
|SN74AHCT125|20||all inputs at 5V or GND|
|SN74AHCT1G00|10||all inputs at 5V or GND|
|SN74AHCT1G00|10||all inputs at 5V or GND|
|SN74AHCT1G08|1,510||10μA for base consumption plus 1,500μA for one input at 3V3|
|LTST-C193KFKT-5A|3,000||power|
|LTST-C193KFKT-5A|3,000||HOST\_TRANSMIT|
|LTST-C193KFKT-5A|3,000||HOST\_ACTIVE|
|TLV70033|9,475||75μA for regulator current plus 9,400μA for output current|
|Totals|20,025|9,400||

19
proto/api.proto Normal file
View file

@ -0,0 +1,19 @@
syntax = "proto3";
package api;
import "echo.proto";
message HostMessage {
uint32 id = 1;
oneof msg {
echo.EchoRequest echo = 2;
}
}
message TargetMessage {
uint32 id = 1;
oneof msg {
echo.EchoResponse echo = 2;
}
}

9
proto/echo.proto Normal file
View file

@ -0,0 +1,9 @@
syntax = "proto3";
package echo;
message EchoRequest {
}
message EchoResponse {
}

29
prototest/.mypy.ini Normal file
View file

@ -0,0 +1,29 @@
[mypy]
files = prototest.py
check_untyped_defs = true
no_implicit_optional = true
strict_equality = true
disallow_any_generics = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
warn_no_return = true
warn_redundant_casts = true
warn_return_any = true
warn_unused_ignores = true
[mypy-api_pb2]
ignore_errors = True
[mypy-test_pb2]
ignore_errors = True
[mypy-echo_pb2]
ignore_errors = True

46
prototest/.ruff.toml Normal file
View file

@ -0,0 +1,46 @@
target-version = "py313"
line-length = 100
exclude = [ "*_pb2.py", "*_pb2.pyi" ]
[lint]
select = [
"A",
"ASYNC",
"B",
"C4",
"E",
"F",
"FBT",
"FURB",
"G",
"I",
"ICN",
"ISC",
"LOG",
"N",
"PERF",
"PIE",
"PL",
"PTH",
"PYI",
"Q",
"RSE",
"RET",
"RUF",
"SIM",
"SLF",
"TID",
"TRY",
"UP",
]
ignore = [
"PLR0912",
"PLR0915",
]
unfixable= [
"F401",
]
[lint.isort]
lines-after-imports = 2
lines-between-types = 1

21
prototest/Makefile Normal file
View file

@ -0,0 +1,21 @@
PROTO_DIR = ../proto
proto_inputs = $(wildcard $(PROTO_DIR)/*.proto)
proto_outputs = $(patsubst %.proto,%_pb2.py,$(notdir $(proto_inputs)))
all: $(proto_outputs)
%_pb2.py: $(PROTO_DIR)/%.proto
@protoc --proto_path=$(PROTO_DIR) --python_out=. --pyi_out=. $^
.PHONY: lint lint-fix
lint:
@ruff format --diff
@ruff check
@mypy
lint-fix:
@ruff format
@ruff check --fix
@mypy

28
prototest/api_pb2.py Normal file
View file

@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# source: api.proto
"""Generated protocol buffer code."""
from google.protobuf.internal import builder as _builder
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import symbol_database as _symbol_database
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
import echo_pb2 as echo__pb2
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\tapi.proto\x12\x03\x61pi\x1a\necho.proto\"C\n\x0bHostMessage\x12\n\n\x02id\x18\x01 \x01(\r\x12!\n\x04\x65\x63ho\x18\x02 \x01(\x0b\x32\x11.echo.EchoRequestH\x00\x42\x05\n\x03msg\"F\n\rTargetMessage\x12\n\n\x02id\x18\x01 \x01(\r\x12\"\n\x04\x65\x63ho\x18\x02 \x01(\x0b\x32\x12.echo.EchoResponseH\x00\x42\x05\n\x03msgb\x06proto3')
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals())
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'api_pb2', globals())
if _descriptor._USE_C_DESCRIPTORS == False:
DESCRIPTOR._options = None
_HOSTMESSAGE._serialized_start=30
_HOSTMESSAGE._serialized_end=97
_TARGETMESSAGE._serialized_start=99
_TARGETMESSAGE._serialized_end=169
# @@protoc_insertion_point(module_scope)

22
prototest/api_pb2.pyi Normal file
View file

@ -0,0 +1,22 @@
import echo_pb2 as _echo_pb2
from google.protobuf import descriptor as _descriptor
from google.protobuf import message as _message
from typing import ClassVar as _ClassVar, Mapping as _Mapping, Optional as _Optional, Union as _Union
DESCRIPTOR: _descriptor.FileDescriptor
class HostMessage(_message.Message):
__slots__ = ["echo", "id"]
ECHO_FIELD_NUMBER: _ClassVar[int]
ID_FIELD_NUMBER: _ClassVar[int]
echo: _echo_pb2.EchoRequest
id: int
def __init__(self, id: _Optional[int] = ..., echo: _Optional[_Union[_echo_pb2.EchoRequest, _Mapping]] = ...) -> None: ...
class TargetMessage(_message.Message):
__slots__ = ["echo", "id"]
ECHO_FIELD_NUMBER: _ClassVar[int]
ID_FIELD_NUMBER: _ClassVar[int]
echo: _echo_pb2.EchoResponse
id: int
def __init__(self, id: _Optional[int] = ..., echo: _Optional[_Union[_echo_pb2.EchoResponse, _Mapping]] = ...) -> None: ...

27
prototest/echo_pb2.py Normal file
View file

@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# source: echo.proto
"""Generated protocol buffer code."""
from google.protobuf.internal import builder as _builder
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import symbol_database as _symbol_database
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\necho.proto\x12\x04\x65\x63ho\"\r\n\x0b\x45\x63hoRequest\"\x0e\n\x0c\x45\x63hoResponseb\x06proto3')
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals())
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'echo_pb2', globals())
if _descriptor._USE_C_DESCRIPTORS == False:
DESCRIPTOR._options = None
_ECHOREQUEST._serialized_start=20
_ECHOREQUEST._serialized_end=33
_ECHORESPONSE._serialized_start=35
_ECHORESPONSE._serialized_end=49
# @@protoc_insertion_point(module_scope)

13
prototest/echo_pb2.pyi Normal file
View file

@ -0,0 +1,13 @@
from google.protobuf import descriptor as _descriptor
from google.protobuf import message as _message
from typing import ClassVar as _ClassVar
DESCRIPTOR: _descriptor.FileDescriptor
class EchoRequest(_message.Message):
__slots__ = []
def __init__(self) -> None: ...
class EchoResponse(_message.Message):
__slots__ = []
def __init__(self) -> None: ...

343
prototest/prototest.py Executable file
View file

@ -0,0 +1,343 @@
#!/usr/bin/env -S uv run
# /// script
# requires-python = ">=3.13"
# dependencies = [
# "fastcrc",
# "prompt-toolkit",
# "protobuf",
# "pyserial",
# "pyserial-asyncio-fast",
# ]
# ///
import argparse
import asyncio
import logging
import random
import struct
import fastcrc
import serial
import serial_asyncio_fast
from google.protobuf.message import DecodeError
from prompt_toolkit.application.current import get_app
from prompt_toolkit.enums import EditingMode
from prompt_toolkit.formatted_text import AnyFormattedText
from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
from prompt_toolkit.patch_stdout import patch_stdout
from prompt_toolkit.shortcuts import PromptSession
from api_pb2 import HostMessage, TargetMessage
SYNC_BYTE = b"\xfc"
MESSAGE_LENGTH_SIZE = 2
CRC_SIZE = 2
uint16_le = struct.Struct("<H")
incoming_messages = asyncio.Queue[TargetMessage]()
outgoing_messages = asyncio.Queue[HostMessage]()
echo_response_queue = asyncio.Queue[int](maxsize=1)
logger = logging.getLogger(__name__)
async def proto_listener(stream: asyncio.StreamReader) -> None:
input_buf = bytearray()
try:
while True:
# look for a sync byte in the buffer; the buffer may have
# leftover bytes from a previous cycle through this loop
# (due to an incomplete message, or a message which failed
# validation)
pre, sync, post = input_buf.partition(SYNC_BYTE)
# if a sync byte was found, reset the buffer to the data
# following it; otherwise, wait for a sync byte to arrive
if len(sync) != 0:
input_buf = post
else:
b = await stream.readexactly(1)
if b != SYNC_BYTE:
continue
logger.debug("got sync byte")
# sync byte was seen, read the length of the message
# payload; if there aren't enough bytes in the buffer,
# wait for them, with a timeout.
if (buf_bytes := len(input_buf)) < MESSAGE_LENGTH_SIZE:
try:
b = await asyncio.wait_for(
stream.readexactly(MESSAGE_LENGTH_SIZE - buf_bytes), timeout=0.5
)
except TimeoutError:
# restart loop, since the sync byte seen wasn't
# the beginning of a message, or the sender
# stopped sending
#
# Note: this assumes that stream.readexactly will
# not consume any bytes from the stream's internal
# buffer if the operation is cancelled
logger.debug("timeout reading message length")
continue
else:
input_buf.extend(b)
# get the message length from the first bytes of the
# buffer
message_length = uint16_le.unpack(input_buf[0:MESSAGE_LENGTH_SIZE])[0]
# if there aren't enough bytes in the buffer for the
# message, wait for them to arrive, with a timeout
if (buf_bytes := len(input_buf[MESSAGE_LENGTH_SIZE:])) < message_length:
try:
# a loop is necessary here so that the timeout
# will apply to each byte, not to the entire
# sequence of bytes needed
for _i in range(message_length - buf_bytes):
b = await asyncio.wait_for(stream.readexactly(1), timeout=0.25)
input_buf.extend(b)
except TimeoutError:
# restart loop, since the sync byte seen wasn't
# the beginning of a message, or the sender
# stopped sending
#
# Note: this assumes that stream.readexactly will
# not consume any bytes from the stream's internal
# buffer if the operation is cancelled
logger.debug("timeout reading message payload")
continue
# message payload has been read, read the CRC; if there
# aren't enough bytes in the buffer, wait for them, with a
# timeout.
if (buf_bytes := len(input_buf[MESSAGE_LENGTH_SIZE + message_length :])) < CRC_SIZE:
try:
b = await asyncio.wait_for(
stream.readexactly(CRC_SIZE - buf_bytes), timeout=0.5
)
except TimeoutError:
# restart loop, since the sync byte seen wasn't
# the beginning of a message, or the sender
# stopped sending
#
# Note: this assumes that stream.readexactly will
# not consume any bytes from the stream's internal
# buffer if the operation is cancelled
logger.debug("timeout reading message CRC")
continue
else:
input_buf.extend(b)
# at this point there is a potentially-complete-and-valid
# message in input_buf, so check its CRC to confirm
message_bytes = bytes(input_buf[MESSAGE_LENGTH_SIZE:-CRC_SIZE])
expected_crc = uint16_le.unpack(input_buf[-CRC_SIZE:])[0]
message_crc = fastcrc.crc16.spi_fujitsu(message_bytes)
if message_crc != expected_crc:
logger.error(
"CRC mismatch: expected 0x%04x - received 0x%04x", expected_crc, message_crc
)
continue
input_buf.clear()
msg = TargetMessage()
try:
msg.ParseFromString(message_bytes)
except DecodeError:
logger.error("Message could not be decoded, dropping") # noqa: TRY400
continue
logger.debug("Received: %s", msg)
await incoming_messages.put(msg)
except asyncio.CancelledError:
logger.info("Protobuf Listener task cancelled.")
async def proto_sender(stream: asyncio.StreamWriter) -> None:
try:
while True:
msg = await outgoing_messages.get()
logger.debug("Sending: %s", msg)
output_buf = bytearray()
output_buf.extend(SYNC_BYTE)
msg_buf = msg.SerializeToString()
output_buf.extend(uint16_le.pack(len(msg_buf)))
output_buf.extend(msg_buf)
message_crc = fastcrc.crc16.spi_fujitsu(msg_buf)
output_buf.extend(uint16_le.pack(message_crc))
stream.write(output_buf)
await stream.drain()
outgoing_messages.task_done()
except asyncio.CancelledError:
logger.info("Protobuf Sender task cancelled.")
class InvalidMessageError(Exception):
"""Indicates that a malformed or unsupported Protocol Buffers message was received."""
def __str__(self) -> str:
return f"Invalid Message: {super()}"
def invalid_message(msg: str) -> None:
raise InvalidMessageError(msg)
async def message_handler() -> None:
try:
while True:
msg = await incoming_messages.get()
try:
if not msg.HasField("msg"):
invalid_message("missing 'msg' field")
await handle_target_message(msg)
except InvalidMessageError as ex:
logger.error(ex) # noqa: TRY400
incoming_messages.task_done()
except asyncio.CancelledError:
logger.info("Message Handler task cancelled.")
async def handle_target_message(tgt: TargetMessage) -> None:
match tgt.WhichOneof("msg"):
case "echo":
await echo_response_queue.put(tgt.id)
case "test":
pass
case _:
invalid_message("response: unknown type")
async def message_sender() -> None:
try:
while True:
msg = HostMessage()
msg.id = random.randint(0, 2**22)
msg.echo.SetInParent()
await outgoing_messages.put(msg)
try:
response = await asyncio.wait_for(echo_response_queue.get(), timeout=2)
except TimeoutError:
logger.error("Timeout waiting for echo response") # noqa: TRY400
else:
if response != msg.id:
logger.error("Incorrect echo response: expected %d - got %d", msg.id, response)
await asyncio.sleep(5)
except asyncio.CancelledError:
logger.info("Message Sender task cancelled.")
async def shell() -> None:
bindings = KeyBindings()
@bindings.add("f4")
def _(event: KeyPressEvent) -> None:
app = event.app
if app.editing_mode == EditingMode.VI:
app.editing_mode = EditingMode.EMACS
else:
app.editing_mode = EditingMode.VI
@bindings.add("f12")
def _(event: KeyPressEvent) -> None:
if logger.level == logging.DEBUG:
logger.setLevel(logging.INFO)
else:
logger.setLevel(logging.DEBUG)
def bottom_toolbar() -> AnyFormattedText:
edit_mode = "Vi" if get_app().editing_mode == EditingMode.VI else "Emacs"
debug_mode = "Debug" if logger.level == logging.DEBUG else "Info"
return [("class:toolbar", f" [F4] {edit_mode} | [F12] {debug_mode} ")]
session = PromptSession[str](
"prototest> ",
enable_history_search=True,
key_bindings=bindings,
bottom_toolbar=bottom_toolbar,
)
while True:
try:
result = await session.prompt_async()
except (EOFError, KeyboardInterrupt):
raise SystemExit(0) from None
else:
print(f'You said: "{result}"')
async def main(args: argparse.Namespace) -> None:
with patch_stdout() as out:
logging.basicConfig(
stream=out, format="%(asctime)s %(levelname)s:%(message)s", level=logging.INFO
)
reader, writer = await serial_asyncio_fast.open_serial_connection(
url=args.protobuf_interface,
baudrate=38400,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
)
async with asyncio.TaskGroup() as tg:
tg.create_task(proto_listener(reader))
tg.create_task(proto_sender(writer))
tg.create_task(message_handler())
tg.create_task(message_sender())
tg.create_task(shell())
logger.info("Quitting.")
if __name__ == "__main__":
parser = argparse.ArgumentParser(
prog="prototest",
description="Tool for testing muart-failsafe protobuf interface.",
epilog="",
)
parser.add_argument("protobuf_interface", help="Serial port connected to protobuf interface")
args = parser.parse_args()
asyncio.run(main(args))