Compare commits

...

No commits in common. "main" and "crc-stream" have entirely different histories.

35 changed files with 3616 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,17 @@
[target.'cfg(all(target_arch = "arm", target_os = "none"))']
runner = [
"probe-rs",
"run",
"--chip",
"STM32G070KB",
"--log-format",
"{t} {m} {s}",
]
[build]
target = "thumbv6m-none-eabi"
rustdocflags = ["--document-private-items"]
[env]
# for tracing inside Embassy crates
DEFMT_LOG = "trace"

1
firmware/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

1111
firmware/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

74
firmware/Cargo.toml Normal file
View file

@ -0,0 +1,74 @@
[package]
name = "muart-failsafe"
version = "0.1.0"
edition = "2024"
[dependencies]
embassy-executor = { version = "0.7.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 = "0.7.0"
embassy-time = { version = "0.4.0", features = ["tick-hz-32_768"] }
byteorder = { version = "1.5.0", default-features = false }
cortex-m = { version = "0.7.6", features = ["inline-asm", "critical-section-single-core"] }
cortex-m-rt = "0.7.0"
embedded-hal = "1.0.0"
embedded-io = "0.6.1"
embedded-io-async = { version = "0.6.1" }
embedded-resources = { version = "0.2.1", features = ["stm32"] }
portable-atomic = { version = "1.5", features = ["unsafe-assume-single-core"] }
static_cell = "2.1.0"
thiserror = { version = "2.0.12", default-features = false }
micropb = { version = "0.1.1", default-features = false, features = [ "encode", "decode", "container-heapless" ] }
defmt = { version = "1.0.0", optional = true }
defmt-rtt = { version = "1.0.0", optional = true }
panic-probe = { version = "1.0.0", optional = true }
panic-halt = "1.0.0"
[build-dependencies]
micropb-gen = "0.1.0"
[features]
default = ["board-nucleo64", "status-led", "trace"]
board-nucleo64 = []
status-led = []
copy-within = []
trace = [
"dep:defmt",
"dep:defmt-rtt",
"dep:panic-probe",
"panic-probe/print-defmt",
"embassy-stm32/defmt",
"embassy-executor/defmt",
"embassy-time/defmt",
"embassy-time/defmt-timestamp-uptime",
"embassy-futures/defmt"
]
[patch.crates-io]
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
[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

42
firmware/build.rs Normal file
View file

@ -0,0 +1,42 @@
use std::env;
use std::path::Path;
use micropb_gen::{Config, Generator};
fn proto_generate() {
let firmware_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let root_dir = firmware_dir.parent().unwrap();
let proto_dir = Path::new(root_dir).join("proto");
let target_dir = firmware_dir.join("src");
let mut g = Generator::new();
g.use_container_heapless()
.configure(".test.TestResponse.f2", Config::new().max_bytes(16))
.configure(".test.TestResponse.f4", Config::new().max_bytes(8))
.add_protoc_arg(format!("-I{}", proto_dir.display()))
.compile_protos(
&[
proto_dir.join("api.proto"),
proto_dir.join("echo.proto"),
proto_dir.join("test.proto"),
],
target_dir.join("proto.rs"),
)
.unwrap();
println!("cargo:rerun-if-changed={}", proto_dir.display());
}
fn main() {
println!("cargo:rustc-link-arg-bins=--nmagic");
println!("cargo:rustc-link-arg-bins=-Tlink.x");
if env::var("CARGO_FEATURE_TRACE").is_ok() {
println!("cargo:rustc-link-arg-bins=-Tdefmt.x");
}
proto_generate();
}

5
firmware/memory.x Normal file
View file

@ -0,0 +1,5 @@
MEMORY
{
FLASH : ORIGIN = 0x08000000, LENGTH = 128K /* BANK_1 */
RAM : ORIGIN = 0x20000000, LENGTH = 36K /* SRAM */
}

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"

64
firmware/src/crc.rs Normal file
View file

@ -0,0 +1,64 @@
use embassy_stm32::crc;
use embassy_sync::blocking_mutex::raw::ThreadModeRawMutex;
use embassy_sync::mutex;
use static_cell::StaticCell;
struct Shared<'a> {
inner: mutex::Mutex<ThreadModeRawMutex, crc::Crc<'a>>,
}
static STORAGE: StaticCell<Shared> = StaticCell::new();
#[derive(Clone, Copy)]
pub struct Handle {
shared: &'static Shared<'static>,
}
struct Stream<'a> {
guard: mutex::MutexGuard<'a, ThreadModeRawMutex, crc::Crc<'a>>,
}
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 shared_ref = STORAGE.init(Shared {
inner: mutex::Mutex::new(crc::Crc::new(r.crc_engine, cfg)),
});
Handle { shared: shared_ref }
}
#[allow(clippy::cast_possible_truncation)]
pub async fn compute(&self, data: &[u8]) -> u16 {
let mut guard = self.shared.inner.lock().await;
let engine = &mut *guard;
engine.reset();
engine.feed_bytes(data) as u16
}
pub async fn stream(&self) -> Stream {
let mut guard = self.shared.inner.lock().await;
let engine = &mut *guard;
engine.reset();
Stream { guard }
}
}
impl Stream<'_> {
#[allow(clippy::cast_possible_truncation)]
pub fn compute(&mut self, data: &[u8]) -> u16 {
let engine = &mut *self.guard;
engine.feed_bytes(data) as u16
}
}

View file

@ -0,0 +1,142 @@
use crate::io;
use crate::println;
use crate::utils;
use embassy_time::Duration;
use embedded_io::ReadReady;
use embedded_io_async::Read;
use micropb::heapless::Vec;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum FramingError<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 FrameReceiver<const N: usize, T>
where
T: Read + ReadReady,
{
pub buf: &'static mut Vec<u8, N>,
rx: T,
sync: u8,
first_byte_timeout: Duration,
between_bytes_timeout: Duration,
}
impl<const N: usize, T> FrameReceiver<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,
}
}
pub async fn wait_for_sync(&mut self) -> Result<(), FramingError<T>> {
// 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 {
println!("waiting for sync byte");
match self.rx.read(&mut buf[..]).await {
Ok(_) => {
println!("rx byte {=u8:#x}", buf[0]);
if buf[0] == self.sync {
break;
}
}
Err(e) => {
println!("read error");
return Err(FramingError::Other(e));
}
}
}
}
Ok(())
}
pub async fn read_frame_fragment(
&mut self,
fragment_pos: usize,
fragment_len: usize,
) -> Result<(), FramingError<T>> {
// 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(FramingError::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(FramingError::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(FramingError::Other(source))
}
}
}
}

104
firmware/src/io/mod.rs Normal file
View file

@ -0,0 +1,104 @@
use embassy_futures::select::*;
use embassy_time::{Duration, Timer};
use embedded_io::ReadReady;
use embedded_io_async::Read;
use thiserror::Error;
pub mod framed_rx;
#[derive(Error, Debug)]
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(())
}

212
firmware/src/lib.rs Normal file
View file

@ -0,0 +1,212 @@
#![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 embassy_sync::blocking_mutex::raw::ThreadModeRawMutex;
use embassy_sync::channel;
use embedded_resources::resource_group;
use static_cell::StaticCell;
use tasks::*;
#[cfg(feature = "trace")]
pub use defmt::println;
#[cfg(feature = "trace")]
use defmt_rtt as _;
#[cfg(not(feature = "trace"))]
#[macro_export]
// Empty version of 'println' macro for use when the tracing feature
// is not enabled; this ensures that all calls to 'println' are
// compiled out.
macro_rules! println {
() => {};
($($arg:tt)*) => {};
}
#[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>;
});
type TargetMessageChannel = channel::Channel<ThreadModeRawMutex, proto::api_::TargetMessage, 3>;
type TargetMessageGenerator =
channel::Sender<'static, ThreadModeRawMutex, proto::api_::TargetMessage, 3>;
type TargetMessagePublisher =
channel::Receiver<'static, ThreadModeRawMutex, proto::api_::TargetMessage, 3>;
static TARGET_MESSAGE_CHANNEL: StaticCell<TargetMessageChannel> = StaticCell::new();
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));
let tgt_msg_channel = TARGET_MESSAGE_CHANNEL.init(TargetMessageChannel::new());
host_interface::start(
host_interface_resources!(p),
spawner,
crc_handle,
tgt_msg_channel.receiver(),
tgt_msg_channel.sender(),
);
heat_pump_interface::start(heat_pump_interface_resources!(p), spawner);
thermostat_interface::start(thermostat_interface_resources!(p), spawner);
}
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>();
}

15
firmware/src/main.rs Normal file
View file

@ -0,0 +1,15 @@
#![no_std]
#![no_main]
use embassy_executor::Spawner;
#[cfg(not(feature = "trace"))]
use panic_halt as _;
#[cfg(feature = "trace")]
use panic_probe as _;
#[embassy_executor::main]
async fn main(spawner: Spawner) {
muart_failsafe::spawn_tasks(spawner);
}

664
firmware/src/proto.rs Normal file
View file

@ -0,0 +1,664 @@
pub mod echo_ {
#[derive(Debug, Clone)]
pub struct EchoRequest {
pub r#data: u32,
}
impl ::core::default::Default for EchoRequest {
fn default() -> Self {
Self {
r#data: ::core::default::Default::default(),
}
}
}
impl ::core::cmp::PartialEq for EchoRequest {
fn eq(&self, other: &Self) -> bool {
let mut ret = true;
ret &= (self.r#data == other.r#data);
ret
}
}
impl EchoRequest {
///Return a reference to `data`
#[inline]
pub fn r#data(&self) -> &u32 {
&self.r#data
}
///Return a mutable reference to `data`
#[inline]
pub fn mut_data(&mut self) -> &mut u32 {
&mut self.r#data
}
///Set the value of `data`
#[inline]
pub fn set_data(&mut self, value: u32) -> &mut Self {
self.r#data = value.into();
self
}
///Builder method that sets the value of `data`. Useful for initializing the message.
#[inline]
pub fn init_data(mut self, value: u32) -> Self {
self.r#data = value.into();
self
}
}
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),
1u32 => {
let mut_ref = &mut self.r#data;
{
let val = decoder.decode_varint32()?;
let val_ref = &val;
if *val_ref != 0 {
*mut_ref = val as _;
}
};
}
_ => {
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};
{
let val_ref = &self.r#data;
if *val_ref != 0 {
encoder.encode_varint32(8u32)?;
encoder.encode_varint32(*val_ref as _)?;
}
}
Ok(())
}
fn compute_size(&self) -> usize {
use ::micropb::{PbVec, PbMap, PbString, FieldEncode};
let mut size = 0;
{
let val_ref = &self.r#data;
if *val_ref != 0 {
size += 1usize + ::micropb::size::sizeof_varint32(*val_ref as _);
}
}
size
}
}
#[derive(Debug, Clone)]
pub struct EchoResponse {
pub r#data: u32,
}
impl ::core::default::Default for EchoResponse {
fn default() -> Self {
Self {
r#data: ::core::default::Default::default(),
}
}
}
impl ::core::cmp::PartialEq for EchoResponse {
fn eq(&self, other: &Self) -> bool {
let mut ret = true;
ret &= (self.r#data == other.r#data);
ret
}
}
impl EchoResponse {
///Return a reference to `data`
#[inline]
pub fn r#data(&self) -> &u32 {
&self.r#data
}
///Return a mutable reference to `data`
#[inline]
pub fn mut_data(&mut self) -> &mut u32 {
&mut self.r#data
}
///Set the value of `data`
#[inline]
pub fn set_data(&mut self, value: u32) -> &mut Self {
self.r#data = value.into();
self
}
///Builder method that sets the value of `data`. Useful for initializing the message.
#[inline]
pub fn init_data(mut self, value: u32) -> Self {
self.r#data = value.into();
self
}
}
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),
1u32 => {
let mut_ref = &mut self.r#data;
{
let val = decoder.decode_varint32()?;
let val_ref = &val;
if *val_ref != 0 {
*mut_ref = val as _;
}
};
}
_ => {
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};
{
let val_ref = &self.r#data;
if *val_ref != 0 {
encoder.encode_varint32(8u32)?;
encoder.encode_varint32(*val_ref as _)?;
}
}
Ok(())
}
fn compute_size(&self) -> usize {
use ::micropb::{PbVec, PbMap, PbString, FieldEncode};
let mut size = 0;
{
let val_ref = &self.r#data;
if *val_ref != 0 {
size += 1usize + ::micropb::size::sizeof_varint32(*val_ref as _);
}
}
size
}
}
}
pub mod test_ {
#[derive(Debug, Clone)]
pub struct TestResponse {
pub r#f1: u32,
pub r#f2: ::micropb::heapless::String<16>,
pub r#f3: bool,
pub r#f4: ::micropb::heapless::Vec<u8, 8>,
}
impl ::core::default::Default for TestResponse {
fn default() -> Self {
Self {
r#f1: ::core::default::Default::default(),
r#f2: ::core::default::Default::default(),
r#f3: ::core::default::Default::default(),
r#f4: ::core::default::Default::default(),
}
}
}
impl ::core::cmp::PartialEq for TestResponse {
fn eq(&self, other: &Self) -> bool {
let mut ret = true;
ret &= (self.r#f1 == other.r#f1);
ret &= (self.r#f2 == other.r#f2);
ret &= (self.r#f3 == other.r#f3);
ret &= (self.r#f4 == other.r#f4);
ret
}
}
impl TestResponse {
///Return a reference to `f1`
#[inline]
pub fn r#f1(&self) -> &u32 {
&self.r#f1
}
///Return a mutable reference to `f1`
#[inline]
pub fn mut_f1(&mut self) -> &mut u32 {
&mut self.r#f1
}
///Set the value of `f1`
#[inline]
pub fn set_f1(&mut self, value: u32) -> &mut Self {
self.r#f1 = value.into();
self
}
///Builder method that sets the value of `f1`. Useful for initializing the message.
#[inline]
pub fn init_f1(mut self, value: u32) -> Self {
self.r#f1 = value.into();
self
}
///Return a reference to `f2`
#[inline]
pub fn r#f2(&self) -> &::micropb::heapless::String<16> {
&self.r#f2
}
///Return a mutable reference to `f2`
#[inline]
pub fn mut_f2(&mut self) -> &mut ::micropb::heapless::String<16> {
&mut self.r#f2
}
///Set the value of `f2`
#[inline]
pub fn set_f2(&mut self, value: ::micropb::heapless::String<16>) -> &mut Self {
self.r#f2 = value.into();
self
}
///Builder method that sets the value of `f2`. Useful for initializing the message.
#[inline]
pub fn init_f2(mut self, value: ::micropb::heapless::String<16>) -> Self {
self.r#f2 = value.into();
self
}
///Return a reference to `f3`
#[inline]
pub fn r#f3(&self) -> &bool {
&self.r#f3
}
///Return a mutable reference to `f3`
#[inline]
pub fn mut_f3(&mut self) -> &mut bool {
&mut self.r#f3
}
///Set the value of `f3`
#[inline]
pub fn set_f3(&mut self, value: bool) -> &mut Self {
self.r#f3 = value.into();
self
}
///Builder method that sets the value of `f3`. Useful for initializing the message.
#[inline]
pub fn init_f3(mut self, value: bool) -> Self {
self.r#f3 = value.into();
self
}
///Return a reference to `f4`
#[inline]
pub fn r#f4(&self) -> &::micropb::heapless::Vec<u8, 8> {
&self.r#f4
}
///Return a mutable reference to `f4`
#[inline]
pub fn mut_f4(&mut self) -> &mut ::micropb::heapless::Vec<u8, 8> {
&mut self.r#f4
}
///Set the value of `f4`
#[inline]
pub fn set_f4(&mut self, value: ::micropb::heapless::Vec<u8, 8>) -> &mut Self {
self.r#f4 = value.into();
self
}
///Builder method that sets the value of `f4`. Useful for initializing the message.
#[inline]
pub fn init_f4(mut self, value: ::micropb::heapless::Vec<u8, 8>) -> Self {
self.r#f4 = value.into();
self
}
}
impl ::micropb::MessageDecode for TestResponse {
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#f1;
{
let val = decoder.decode_varint32()?;
let val_ref = &val;
if *val_ref != 0 {
*mut_ref = val as _;
}
};
}
2u32 => {
let mut_ref = &mut self.r#f2;
{
decoder
.decode_string(mut_ref, ::micropb::Presence::Implicit)?;
};
}
3u32 => {
let mut_ref = &mut self.r#f3;
{
let val = decoder.decode_bool()?;
let val_ref = &val;
if *val_ref {
*mut_ref = val as _;
}
};
}
4u32 => {
let mut_ref = &mut self.r#f4;
{
decoder
.decode_bytes(mut_ref, ::micropb::Presence::Implicit)?;
};
}
_ => {
decoder.skip_wire_value(tag.wire_type())?;
}
}
}
Ok(())
}
}
impl ::micropb::MessageEncode for TestResponse {
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#f1;
if *val_ref != 0 {
encoder.encode_varint32(8u32)?;
encoder.encode_varint32(*val_ref as _)?;
}
}
{
let val_ref = &self.r#f2;
if !val_ref.is_empty() {
encoder.encode_varint32(18u32)?;
encoder.encode_string(val_ref)?;
}
}
{
let val_ref = &self.r#f3;
if *val_ref {
encoder.encode_varint32(24u32)?;
encoder.encode_bool(*val_ref)?;
}
}
{
let val_ref = &self.r#f4;
if !val_ref.is_empty() {
encoder.encode_varint32(34u32)?;
encoder.encode_bytes(val_ref)?;
}
}
Ok(())
}
fn compute_size(&self) -> usize {
use ::micropb::{PbVec, PbMap, PbString, FieldEncode};
let mut size = 0;
{
let val_ref = &self.r#f1;
if *val_ref != 0 {
size += 1usize + ::micropb::size::sizeof_varint32(*val_ref as _);
}
}
{
let val_ref = &self.r#f2;
if !val_ref.is_empty() {
size += 1usize + ::micropb::size::sizeof_len_record(val_ref.len());
}
}
{
let val_ref = &self.r#f3;
if *val_ref {
size += 1usize + 1;
}
}
{
let val_ref = &self.r#f4;
if !val_ref.is_empty() {
size += 1usize + ::micropb::size::sizeof_len_record(val_ref.len());
}
}
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#msg: ::core::option::Option<HostMessage_::Msg>,
}
impl ::core::default::Default for HostMessage {
fn default() -> Self {
Self {
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#msg == other.r#msg);
ret
}
}
impl HostMessage {}
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 = 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};
if let Some(oneof) = &self.r#msg {
match &*oneof {
HostMessage_::Msg::Echo(val_ref) => {
let val_ref = &*val_ref;
encoder.encode_varint32(10u32)?;
val_ref.encode_len_delimited(encoder)?;
}
}
}
Ok(())
}
fn compute_size(&self) -> usize {
use ::micropb::{PbVec, PbMap, PbString, FieldEncode};
let mut size = 0;
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),
Test(super::super::test_::TestResponse),
}
}
#[derive(Debug, Clone)]
pub struct TargetMessage {
pub r#msg: ::core::option::Option<TargetMessage_::Msg>,
}
impl ::core::default::Default for TargetMessage {
fn default() -> Self {
Self {
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#msg == other.r#msg);
ret
}
}
impl TargetMessage {}
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 = 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)?;
}
2u32 => {
let mut_ref = loop {
if let ::core::option::Option::Some(variant) = &mut self
.r#msg
{
if let TargetMessage_::Msg::Test(variant) = &mut *variant {
break &mut *variant;
}
}
self.r#msg = ::core::option::Option::Some(
TargetMessage_::Msg::Test(
::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};
if let Some(oneof) = &self.r#msg {
match &*oneof {
TargetMessage_::Msg::Echo(val_ref) => {
let val_ref = &*val_ref;
encoder.encode_varint32(10u32)?;
val_ref.encode_len_delimited(encoder)?;
}
TargetMessage_::Msg::Test(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;
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(),
);
}
TargetMessage_::Msg::Test(val_ref) => {
let val_ref = &*val_ref;
size
+= 1usize
+ ::micropb::size::sizeof_len_record(
val_ref.compute_size(),
);
}
}
}
size
}
}
}

4
firmware/src/tasks.rs Normal file
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,51 @@
use embassy_executor::Spawner;
use embassy_stm32::usart;
use micropb::heapless::Vec;
use static_cell::StaticCell;
type UartRxBuf = Vec<u8, 32>;
type UartTxBuf = Vec<u8, 32>;
static UART_RX_BUF: StaticCell<UartRxBuf> = StaticCell::new();
static UART_TX_BUF: StaticCell<UartTxBuf> = 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 rx_buf = UART_RX_BUF.init(UartRxBuf::new());
let tx_buf = UART_TX_BUF.init(UartTxBuf::new());
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,
tx_buf,
rx_buf,
crate::Irqs,
config,
)
.unwrap()
.split();
spawner.must_spawn(tx_task(Tx { uart: uart_tx }));
spawner.must_spawn(rx_task(Rx { uart: uart_rx }));
}
#[embassy_executor::task]
async fn tx_task(_tx: Tx) {}
#[embassy_executor::task]
async fn rx_task(_rx: Rx) {}

View file

@ -0,0 +1,305 @@
use crate::{crc, io, println, utils};
use byteorder::{ByteOrder, LittleEndian};
use embassy_executor::Spawner;
use embassy_stm32::{gpio, usart};
use embassy_time::Duration;
use embedded_io_async::Write;
use static_cell::StaticCell;
use micropb::{DecodeError, MessageDecode, MessageEncode, PbDecoder, PbEncoder, heapless::Vec};
use crate::proto::api_::*;
use crate::proto::echo_::*;
const SYNC_BYTE: u8 = 0xfc;
const MESSAGE_LENGTH_SIZE: usize = 2;
const MESSAGE_CRC_SIZE: usize = 2;
const MAX_INBOUND_MESSAGE_SIZE: usize = MESSAGE_LENGTH_SIZE + 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_INBOUND_MESSAGE_SIZE>;
static MESSAGE_BUF: StaticCell<MessageBuf> = StaticCell::new();
struct Tx {
uart: usart::BufferedUartTx<'static>,
channel: crate::TargetMessagePublisher,
crc: crc::Handle,
}
#[allow(dead_code)]
struct Rx {
message_buf: &'static mut MessageBuf,
uart: usart::BufferedUartRx<'static>,
channel: crate::TargetMessageGenerator,
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,
publisher: crate::TargetMessagePublisher,
generator: crate::TargetMessageGenerator,
) {
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(tx_task(Tx {
uart: uart_tx,
channel: publisher,
crc,
}));
spawner.must_spawn(rx_task(Rx {
message_buf,
uart: uart_rx,
channel: generator,
crc,
clr_host_transmit,
set_host_transmit,
host_active_led,
}));
}
#[embassy_executor::task]
async fn tx_task(mut tx: Tx) {
println!("tx_task started");
let mut buf = Vec::<u8, 128>::new();
loop {
let msg = tx.channel.receive().await;
#[allow(clippy::cast_possible_truncation)]
let encoded_size = (msg.compute_size() as u16).to_le_bytes();
buf.clear();
unsafe {
// sync byte
buf.push_unchecked(SYNC_BYTE);
// two bytes for length of message (not including CRC)
buf.push_unchecked(encoded_size[0]);
buf.push_unchecked(encoded_size[1]);
}
let mut encoder = PbEncoder::new(&mut buf);
msg.encode(&mut encoder).unwrap();
let crc = (tx.crc.compute(&buf[3..]).await).to_le_bytes();
buf.push(crc[0]).unwrap();
buf.push(crc[1]).unwrap();
tx.uart.write_all(&buf).await.unwrap();
}
}
#[embassy_executor::task]
#[allow(clippy::too_many_lines)]
async fn rx_task(rx: Rx) {
println!("rx_task started");
let mut frx = io::framed_rx::FrameReceiver::new(
rx.message_buf,
rx.uart,
SYNC_BYTE,
Duration::from_millis(250),
Duration::from_millis(50),
);
loop {
if frx.wait_for_sync().await.is_err() {
println!("error while waiting for sync");
continue;
}
println!("received sync byte from host");
// sync byte was seen, read the length of the message; if
// there aren't enough bytes in the buffer, wait for them,
// with timeouts
match frx.read_frame_fragment(0, MESSAGE_LENGTH_SIZE).await {
Ok(()) => {}
Err(io::framed_rx::FramingError::BufferCapacity) => {
println!("insufficient space to read message length");
frx.buf.clear();
continue;
}
Err(io::framed_rx::FramingError::Timeout) => {
// restart loop, since the sync byte seen wasn't
// the beginning of a message, or the sender
// stopped sending
println!("timeout waiting for message length");
continue;
}
Err(io::framed_rx::FramingError::Other(_)) => {
// restart loop when a USART error occurs
println!("usart error waiting for message length");
continue;
}
}
// get the message length from the buffer
let message_len = LittleEndian::read_u16(&frx.buf[..MESSAGE_LENGTH_SIZE]) as usize;
println!("receiving message of {=u16} bytes", message_len as u16);
// read the message; if there aren't enough bytes in the
// buffer for the message, wait for them, with timeouts
let message_pos = MESSAGE_LENGTH_SIZE;
match frx.read_frame_fragment(message_pos, message_len).await {
Ok(()) => {}
Err(io::framed_rx::FramingError::BufferCapacity) => {
println!("insufficient space to read message body");
frx.buf.clear();
continue;
}
Err(io::framed_rx::FramingError::Timeout) => {
// restart loop, since the sync byte seen wasn't
// the beginning of a message, or the sender
// stopped sending
println!("timeout waiting for message body");
continue;
}
Err(io::framed_rx::FramingError::Other(_)) => {
// restart loop when a USART error occurs
println!("usart error waiting for message length");
continue;
}
}
// read the CRC-16 of the message; if there aren't enough
// bytes in the buffer, wait for them, with timeouts
let crc_pos = message_pos + message_len;
match frx.read_frame_fragment(crc_pos, MESSAGE_CRC_SIZE).await {
Ok(()) => {}
Err(io::framed_rx::FramingError::BufferCapacity) => {
println!("insufficient space to read CRC-16");
frx.buf.clear();
continue;
}
Err(io::framed_rx::FramingError::Timeout) => {
// restart loop, since the sync byte seen wasn't
// the beginning of a message, or the sender
// stopped sending
println!("timeout waiting for message CRC-16");
continue;
}
Err(io::framed_rx::FramingError::Other(_)) => {
// restart loop when a USART error occurs
println!("usart error waiting for message length");
continue;
}
}
// 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 = rx
.crc
.compute(&frx.buf[message_pos..message_pos + message_len])
.await;
if computed_crc != expected_crc {
println!(
"CRC-16 mismatch: expected {=u16:#x} - computed {=u16:#x}",
expected_crc, computed_crc
);
continue;
}
println!("message frame is 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 = HostMessage::default();
match msg.decode(&mut decoder, message_len) {
Ok(()) => {
println!("message decoded");
handle_host_message(msg, rx.channel).await;
}
Err(DecodeError::VarIntLimit) => {
println!("decoder error: VarInt too long");
}
Err(DecodeError::UnexpectedEof) => {
println!("decoder error: unexpected EOF");
}
Err(DecodeError::Deprecation) => {
println!("decoder error: deprecated wire type found");
}
Err(DecodeError::UnknownWireType) => {
println!("decoder error: unknown wire type found");
}
Err(DecodeError::ZeroField) => {
println!("decoder error: invalid field number 0");
}
Err(DecodeError::Utf8) => {
println!("decoder error: invalid UTF-8 string");
}
Err(DecodeError::Capacity) => {
println!("decoder error: capacity of fixed-length field exceeed");
}
Err(DecodeError::WrongLen) => {
println!("decoder error: length-delimited-record too long");
}
Err(_) => {
println!("decoder error: unknown");
}
}
utils::remove_leading_bytes(frx.buf, crc_pos + MESSAGE_CRC_SIZE);
}
}
async fn handle_host_message(host: HostMessage, generator: crate::TargetMessageGenerator) {
match host.msg {
None => {
println!("message is empty, ignoring");
}
Some(HostMessage_::Msg::Echo(echo)) => {
println!("replying to echo request with data: {=u32:#x}", echo.data);
generator
.send(TargetMessage {
msg: Some(TargetMessage_::Msg::Echo(EchoResponse { data: echo.data })),
})
.await;
}
}
}

View file

@ -0,0 +1,28 @@
#[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();
}
}

View file

@ -0,0 +1,51 @@
use embassy_executor::Spawner;
use embassy_stm32::usart;
use micropb::heapless::Vec;
use static_cell::StaticCell;
type UartRxBuf = Vec<u8, 32>;
type UartTxBuf = Vec<u8, 32>;
static UART_RX_BUF: StaticCell<UartRxBuf> = StaticCell::new();
static UART_TX_BUF: StaticCell<UartTxBuf> = 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 rx_buf = UART_RX_BUF.init(UartRxBuf::new());
let tx_buf = UART_TX_BUF.init(UartTxBuf::new());
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,
tx_buf,
rx_buf,
crate::Irqs,
config,
)
.unwrap()
.split();
spawner.must_spawn(tx_task(Tx { uart: uart_tx }));
spawner.must_spawn(rx_task(Rx { uart: uart_rx }));
}
#[embassy_executor::task]
async fn tx_task(_tx: Tx) {}
#[embassy_executor::task]
async fn rx_task(_rx: Rx) {}

12
firmware/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);
}

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";
import "test.proto";
message HostMessage {
oneof msg {
echo.EchoRequest echo = 1;
}
}
message TargetMessage {
oneof msg {
echo.EchoResponse echo = 1;
test.TestResponse test = 2;
}
}

11
proto/echo.proto Normal file
View file

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

10
proto/test.proto Normal file
View file

@ -0,0 +1,10 @@
syntax = "proto3";
package test;
message TestResponse {
uint32 f1 = 1;
string f2 = 2;
bool f3 = 3;
bytes f4 = 4;
}

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

29
prototest/api_pb2.py Normal file
View file

@ -0,0 +1,29 @@
# -*- 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
import test_pb2 as test__pb2
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\tapi.proto\x12\x03\x61pi\x1a\necho.proto\x1a\ntest.proto\"7\n\x0bHostMessage\x12!\n\x04\x65\x63ho\x18\x01 \x01(\x0b\x32\x11.echo.EchoRequestH\x00\x42\x05\n\x03msg\"^\n\rTargetMessage\x12\"\n\x04\x65\x63ho\x18\x01 \x01(\x0b\x32\x12.echo.EchoResponseH\x00\x12\"\n\x04test\x18\x02 \x01(\x0b\x32\x12.test.TestResponseH\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=42
_HOSTMESSAGE._serialized_end=97
_TARGETMESSAGE._serialized_start=99
_TARGETMESSAGE._serialized_end=193
# @@protoc_insertion_point(module_scope)

21
prototest/api_pb2.pyi Normal file
View file

@ -0,0 +1,21 @@
import echo_pb2 as _echo_pb2
import test_pb2 as _test_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"]
ECHO_FIELD_NUMBER: _ClassVar[int]
echo: _echo_pb2.EchoRequest
def __init__(self, echo: _Optional[_Union[_echo_pb2.EchoRequest, _Mapping]] = ...) -> None: ...
class TargetMessage(_message.Message):
__slots__ = ["echo", "test"]
ECHO_FIELD_NUMBER: _ClassVar[int]
TEST_FIELD_NUMBER: _ClassVar[int]
echo: _echo_pb2.EchoResponse
test: _test_pb2.TestResponse
def __init__(self, echo: _Optional[_Union[_echo_pb2.EchoResponse, _Mapping]] = ..., test: _Optional[_Union[_test_pb2.TestResponse, _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\"\x1b\n\x0b\x45\x63hoRequest\x12\x0c\n\x04\x64\x61ta\x18\x01 \x01(\r\"\x1c\n\x0c\x45\x63hoResponse\x12\x0c\n\x04\x64\x61ta\x18\x01 \x01(\rb\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=47
_ECHORESPONSE._serialized_start=49
_ECHORESPONSE._serialized_end=77
# @@protoc_insertion_point(module_scope)

17
prototest/echo_pb2.pyi Normal file
View file

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

344
prototest/prototest.py Executable file
View file

@ -0,0 +1,344 @@
#!/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.echo.data)
case "test":
pass
case _:
invalid_message("response: unknown type")
async def message_sender() -> None:
try:
while True:
msg = HostMessage()
msg.echo.data = random.randint(0, 2**22)
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.echo.data:
logger.error(
"Incorrect echo response: expected %d - got %d", msg.echo.data, 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))

25
prototest/test_pb2.py Normal file
View file

@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# source: test.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\ntest.proto\x12\x04test\">\n\x0cTestResponse\x12\n\n\x02\x66\x31\x18\x01 \x01(\r\x12\n\n\x02\x66\x32\x18\x02 \x01(\t\x12\n\n\x02\x66\x33\x18\x03 \x01(\x08\x12\n\n\x02\x66\x34\x18\x04 \x01(\x0c\x62\x06proto3')
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals())
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'test_pb2', globals())
if _descriptor._USE_C_DESCRIPTORS == False:
DESCRIPTOR._options = None
_TESTRESPONSE._serialized_start=20
_TESTRESPONSE._serialized_end=82
# @@protoc_insertion_point(module_scope)

25
prototest/test_pb2.pyi Normal file
View file

@ -0,0 +1,25 @@
from typing import ClassVar as _ClassVar
from typing import Optional as _Optional
from google.protobuf import descriptor as _descriptor
from google.protobuf import message as _message
DESCRIPTOR: _descriptor.FileDescriptor
class TestResponse(_message.Message):
__slots__ = ["f1", "f2", "f3", "f4"]
F1_FIELD_NUMBER: _ClassVar[int]
F2_FIELD_NUMBER: _ClassVar[int]
F3_FIELD_NUMBER: _ClassVar[int]
F4_FIELD_NUMBER: _ClassVar[int]
f1: int
f2: str
f3: bool
f4: bytes
def __init__(
self,
f1: int | None = ...,
f2: str | None = ...,
f3: bool = ...,
f4: bytes | None = ...,
) -> None: ...