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