Compare commits
No commits in common. "main" and "stm32-firmware" have entirely different histories.
main
...
stm32-firm
43 changed files with 3790 additions and 85 deletions
85
README.md
85
README.md
|
|
@ -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.
|
||||
|
||||
##
|
||||
|
||||
## 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)
|
||||
2
firmware/.cargo/config.toml
Normal file
2
firmware/.cargo/config.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[env]
|
||||
CARGO_WORKSPACE_DIR = { value = "", relative = true }
|
||||
1
firmware/.gitignore
vendored
Normal file
1
firmware/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/target
|
||||
1291
firmware/Cargo.lock
generated
Normal file
1291
firmware/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
51
firmware/Cargo.toml
Normal file
51
firmware/Cargo.toml
Normal 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
|
||||
6
firmware/app/.cargo/config.toml
Normal file
6
firmware/app/.cargo/config.toml
Normal 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
50
firmware/app/Cargo.toml
Normal 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
62
firmware/app/build.rs
Normal 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
3
firmware/app/memory.x
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
INCLUDE all-memory.x
|
||||
|
||||
REGION_ALIAS("FLASH", APP)
|
||||
70
firmware/app/src/crc.rs
Normal file
70
firmware/app/src/crc.rs
Normal 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
|
||||
}
|
||||
}
|
||||
214
firmware/app/src/io/framed.rs
Normal file
214
firmware/app/src/io/framed.rs
Normal 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
105
firmware/app/src/io/mod.rs
Normal 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(())
|
||||
}
|
||||
143
firmware/app/src/io/proto.rs
Normal file
143
firmware/app/src/io/proto.rs
Normal 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
185
firmware/app/src/lib.rs
Normal 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
11
firmware/app/src/main.rs
Normal 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
390
firmware/app/src/proto.rs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
4
firmware/app/src/tasks.rs
Normal file
4
firmware/app/src/tasks.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
pub mod heat_pump_interface;
|
||||
pub mod host_interface;
|
||||
pub mod supervisor;
|
||||
pub mod thermostat_interface;
|
||||
57
firmware/app/src/tasks/heat_pump_interface.rs
Normal file
57
firmware/app/src/tasks/heat_pump_interface.rs
Normal 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) {}
|
||||
197
firmware/app/src/tasks/host_interface/mod.rs
Normal file
197
firmware/app/src/tasks/host_interface/mod.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
30
firmware/app/src/tasks/supervisor.rs
Normal file
30
firmware/app/src/tasks/supervisor.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
57
firmware/app/src/tasks/thermostat_interface.rs
Normal file
57
firmware/app/src/tasks/thermostat_interface.rs
Normal 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
12
firmware/app/src/utils.rs
Normal 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);
|
||||
}
|
||||
3
firmware/boot/.cargo/config.toml
Normal file
3
firmware/boot/.cargo/config.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[build]
|
||||
target = "thumbv6m-none-eabi"
|
||||
rustdocflags = ["--document-private-items"]
|
||||
26
firmware/boot/Cargo.toml
Normal file
26
firmware/boot/Cargo.toml
Normal 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
30
firmware/boot/build.rs
Normal 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
3
firmware/boot/memory.x
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
INCLUDE all-memory.x
|
||||
|
||||
REGION_ALIAS("FLASH", BOOT)
|
||||
51
firmware/boot/src/main.rs
Normal file
51
firmware/boot/src/main.rs
Normal 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();
|
||||
}
|
||||
18
firmware/common/all-memory.x
Normal file
18
firmware/common/all-memory.x
Normal 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
1
firmware/run
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
run.sh
|
||||
70
firmware/run.sh
Executable file
70
firmware/run.sh
Executable 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 "${@}"
|
||||
5
firmware/rust-toolchain.toml
Normal file
5
firmware/rust-toolchain.toml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
[toolchain]
|
||||
channel = "stable"
|
||||
profile = "default"
|
||||
components = [ "llvm-tools" ]
|
||||
targets = [ "thumbv6m-none-eabi" ]
|
||||
2
firmware/rustfmt.toml
Normal file
2
firmware/rustfmt.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
edition = "2024"
|
||||
style_edition = "2024"
|
||||
83
hw-notes.md
Normal file
83
hw-notes.md
Normal 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
19
proto/api.proto
Normal 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
9
proto/echo.proto
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
syntax = "proto3";
|
||||
|
||||
package echo;
|
||||
|
||||
message EchoRequest {
|
||||
}
|
||||
|
||||
message EchoResponse {
|
||||
}
|
||||
29
prototest/.mypy.ini
Normal file
29
prototest/.mypy.ini
Normal 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
46
prototest/.ruff.toml
Normal 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
21
prototest/Makefile
Normal 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
28
prototest/api_pb2.py
Normal 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
22
prototest/api_pb2.pyi
Normal 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
27
prototest/echo_pb2.py
Normal 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
13
prototest/echo_pb2.pyi
Normal 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
343
prototest/prototest.py
Executable 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))
|
||||
Loading…
Add table
Add a link
Reference in a new issue