Initial version

This commit is contained in:
Dreaded_X 2023-09-16 04:16:52 +02:00
commit d0efb48941
Signed by: Dreaded_X
GPG Key ID: FA5F485356B0D2D4
16 changed files with 3012 additions and 0 deletions

8
.cargo/config.toml Normal file
View File

@ -0,0 +1,8 @@
[target.'cfg(all(target_arch = "arm", target_os = "none"))']
runner = "./wrapper.sh"
[build]
target = "thumbv6m-none-eabi" # Cortex-M0 and Cortex-M0+
[env]
DEFMT_LOG = "debug"

2
.embed.toml Normal file
View File

@ -0,0 +1,2 @@
[default.general]
chip = "rp2040"

2
.gitignore vendored Normal file
View File

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

2284
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

109
Cargo.toml Normal file
View File

@ -0,0 +1,109 @@
[package]
name = "air_filter"
version = "0.1.0"
edition = "2021"
[dependencies]
cortex-m = { version = "0.7", features = ["inline-asm"] }
cortex-m-rt = "0.7"
defmt = "0.3"
defmt-rtt = "0.4"
embassy-embedded-hal = { version = "0.1.0", features = ["nightly", "defmt"] }
embassy-executor = { version = "0.3", features = [
"arch-cortex-m",
"executor-thread",
"executor-interrupt",
"defmt",
"nightly",
"integrated-timers",
] }
embassy-rp = { version = "0.1", features = [
"defmt",
"unstable-traits",
"nightly",
"unstable-pac",
"time-driver",
"critical-section-impl",
] }
embassy-boot-rp = { version = "0.1", features = ["nightly", "defmt"] }
embassy-boot = { version = "0.1", features = ["nightly", "defmt"] }
embassy-time = { version = "0.1", features = [
"defmt",
"unstable-traits",
"defmt-timestamp-uptime",
"nightly",
] }
embassy-net = { version = "0.1", features = [
"tcp",
"dhcpv4",
"nightly",
"medium-ethernet",
"defmt",
"dns",
] }
embassy-sync = { version = "0.2", features = ["defmt"] }
embassy-futures = { version = "0.1", features = ["defmt"] }
panic-probe = { version = "0.3", features = ["print-defmt"] }
cfg-if = "1.0.0"
static_cell = { version = "1.1", features = ["nightly"] }
cyw43 = { git = "https://github.com/embassy-rs/embassy", features = [
"defmt",
"firmware-logs",
] }
cyw43-pio = { git = "https://github.com/embassy-rs/embassy", features = [
"defmt",
] }
rand = { version = "0.8.5", features = [
"nightly",
"small_rng",
"std_rng",
], default-features = false }
rust-mqtt = { version = "0.1.5", features = [
"defmt",
"no_std",
"tls",
], default-features = false }
const_format = "0.2.31"
git-version = "0.3.5"
serde = { version = "1.0.188", default-features = false, features = ["derive"] }
heapless = { version = "0.7.16", features = ["defmt", "serde"] }
serde-json-core = "0.5.1"
nourl = { version = "0.1.1", features = ["defmt"] }
# Embassy harfcodes a max of 6 dns servers, if there are more it crashes. This is a workaround
# Ideally embassy returns an error instead of crashing...
# Interestingly though, I only get 2 DNS servers...
smoltcp = { version = "0.10.0", default-features = false, features = [
"dns-max-server-count-4",
] }
updater = { version = "0.1.0", path = "../iot_tools/updater" }
[patch.crates-io]
embassy-embedded-hal = { git = "https://github.com/embassy-rs/embassy" }
embassy-executor = { git = "https://github.com/embassy-rs/embassy" }
embassy-rp = { git = "https://github.com/embassy-rs/embassy" }
embassy-time = { git = "https://github.com/embassy-rs/embassy" }
embassy-net = { git = "https://github.com/embassy-rs/embassy" }
embassy-sync = { git = "https://github.com/embassy-rs/embassy" }
embassy-futures = { git = "https://github.com/embassy-rs/embassy" }
embassy-boot-rp = { git = "https://github.com/embassy-rs/embassy" }
embassy-boot = { git = "https://github.com/embassy-rs/embassy" }
# Updated to embedded-io 0.5.0
rust-mqtt = { git = "https://git.huizinga.dev/Dreaded_X/rust-mqtt" }
# Make mqtt:// and mqtts:// actually work
nourl = { git = "https://git.huizinga.dev/Dreaded_X/nourl" }
# Waiting for this to get updated to embedded-io 0.5 properly
reqwless = { path = "../reqwless" }
[features]
include_firmwares = []
[profile.release]
debug = true
opt-level = 's'
codegen-units = 1
lto = true
[build-dependencies]
dotenvy = "0.15.7"

42
build.rs Normal file
View File

@ -0,0 +1,42 @@
use std::env;
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
fn main() {
let out = &PathBuf::from(env::var_os("OUT_DIR").unwrap());
// By default cortex-m-rt expects memory.x, however this causes issues with workspaces as it
// will pick the first file that is found.
// In order to get around this we make a dummy memory.x file
File::create(out.join("memory.x")).unwrap();
// Use memory.x.in as a template for the actual memory.x
let memory = include_str!("memory.x.in")
.replace("{BOOTLOADER}", "BOOTLOADER")
.replace("{ACTIVE}", "FLASH");
// And then include it with a unique name
File::create(out.join("memory_app.x"))
.unwrap()
.write_all(memory.as_bytes())
.unwrap();
println!("cargo:rustc-link-search={}", out.display());
println!("cargo:rerun-if-changed=memory.x.in");
// And link with that one
println!("cargo:rustc-link-arg-bins=-Tmemory_app.x");
println!("cargo:rustc-link-arg-bins=--nmagic");
println!("cargo:rustc-link-arg-bins=-Tlink.x");
println!("cargo:rustc-link-arg-bins=-Tdefmt.x");
if let Ok(dotenv_path) = dotenvy::dotenv() {
println!("cargo:rerun-if-changed={}", dotenv_path.display());
for env_var in dotenvy::dotenv_iter().unwrap() {
let (key, value) = env_var.unwrap();
println!("cargo:rustc-env={key}={value}");
}
}
}

BIN
firmware/43439A0.bin Normal file

Binary file not shown.

BIN
firmware/43439A0_clm.bin Normal file

Binary file not shown.

View File

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

9
firmware/README.md Normal file
View File

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

BIN
key.pub Normal file

Binary file not shown.

22
memory.x.in Normal file
View File

@ -0,0 +1,22 @@
MEMORY {
BOOT2 : ORIGIN = 0x10000000, LENGTH = 0x100
{BOOTLOADER} : ORIGIN = 0x10000100, LENGTH = 24k - 0x100
BOOTLOADER_STATE : ORIGIN = 0x10006000, LENGTH = 4k
{ACTIVE} : ORIGIN = 0x10007000, LENGTH = 876k
DFU : ORIGIN = 0x100E2000, LENGTH = 880k
FW : ORIGIN = 0x101BE000, LENGTH = 256k
CLM : ORIGIN = 0x101FE000, LENGTH = 8k
RAM : ORIGIN = 0x20000000, LENGTH = 264K
}
__bootloader_state_start = ORIGIN(BOOTLOADER_STATE) - ORIGIN(BOOT2);
__bootloader_state_end = ORIGIN(BOOTLOADER_STATE) + LENGTH(BOOTLOADER_STATE) - ORIGIN(BOOT2);
__bootloader_active_start = ORIGIN({ACTIVE}) - ORIGIN(BOOT2);
__bootloader_active_end = ORIGIN({ACTIVE}) + LENGTH({ACTIVE}) - ORIGIN(BOOT2);
__bootloader_dfu_start = ORIGIN(DFU) - ORIGIN(BOOT2);
__bootloader_dfu_end = ORIGIN(DFU) + LENGTH(DFU) - ORIGIN(BOOT2);
__fw_start = ORIGIN(FW);
__clm_start = ORIGIN(CLM);

9
release.sh Executable file
View File

@ -0,0 +1,9 @@
#!/bin/bash
mkdir -p target/firmware
cargo objcopy --release --features=include_firmwares -- -O binary target/firmware/firmware
shasum -a 512 -b target/firmware/firmware | dd ibs=128 count=1 | xxd -p -r > target/firmware/checksum
signify -S -m target/firmware/checksum -s ~/Projects/crypt/R0/private/keys/firmware/airfilter.sec -x target/firmware/checksum.sig
tail -n1 target/firmware/checksum.sig | base64 -d -i | dd ibs=10 skip=1 > target/firmware/signed
cat target/firmware/signed > target/firmware/firmware+signed
cat target/firmware/firmware >> target/firmware/firmware+signed

3
rust-toolchain.toml Normal file
View File

@ -0,0 +1,3 @@
[toolchain]
channel = "nightly"
targets = ["thumbv6m-none-eabi"]

470
src/main.rs Normal file
View File

@ -0,0 +1,470 @@
#![no_std]
#![no_main]
#![feature(type_alias_impl_trait)]
use core::{cell::RefCell, str::from_utf8};
use const_format::formatcp;
use cyw43::PowerManagementMode;
use cyw43_pio::PioSpi;
use defmt::{debug, error, info, warn, Display2Format, Format};
use embassy_boot::{AlignedBuffer, BlockingFirmwareUpdater, FirmwareUpdaterConfig};
use embassy_executor::Spawner;
use embassy_futures::{
select::{select3, select4, Either3},
yield_now,
};
use embassy_net::{dns::DnsQueryType, tcp::TcpSocket, Config, Stack, StackResources};
use embassy_rp::{
bind_interrupts,
clocks::RoscRng,
flash::{Flash, WRITE_SIZE},
gpio::{Flex, Input, Level, Output, Pin, Pull},
peripherals::{DMA_CH1, PIN_23, PIN_25, PIO0},
pio::{self, Pio},
Peripheral,
};
use embassy_sync::blocking_mutex::Mutex;
use embassy_time::{Duration, Ticker, Timer};
use heapless::Vec;
use nourl::Url;
use rand::{
rngs::{SmallRng, StdRng},
RngCore, SeedableRng,
};
use rust_mqtt::{
client::{
client::MqttClient,
client_config::{ClientConfig, MqttVersion},
},
packet::v5::publish_packet::QualityOfService,
};
use serde::{Deserialize, Serialize};
use static_cell::make_static;
use {defmt_rtt as _, panic_probe as _};
bind_interrupts!(struct Irqs {
PIO0_IRQ_0 => pio::InterruptHandler<PIO0>;
});
const ID: &str = env!("ID");
const TOPIC_BASE: &str = formatcp!("pico/{}", ID);
const TOPIC_STATUS: &str = formatcp!("{}/status", TOPIC_BASE);
const TOPIC_UPDATE: &str = formatcp!("{}/update", TOPIC_BASE);
const TOPIC_SET: &str = formatcp!("{}/set", TOPIC_BASE);
const VERSION: &str = git_version::git_version!();
const PUBLIC_SIGNING_KEY: &[u8] = include_bytes!("../key.pub");
const FLASH_SIZE: usize = 2 * 1024 * 1024;
#[derive(Deserialize)]
struct SetMessage {
state: State,
}
#[derive(Serialize)]
struct StateMessage {
state: State,
manual: bool,
}
impl StateMessage {
pub fn new((state, manual): (State, bool)) -> Self {
Self { state, manual }
}
pub fn vec(&self) -> Vec<u8, 64> {
serde_json_core::to_vec(self)
.expect("The buffer should be large enough to contain all the data")
}
}
#[derive(Format, PartialEq, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
enum State {
Off,
Low,
Medium,
High,
}
impl SetMessage {
fn get_state(&self) -> State {
self.state
}
}
struct Controller<'a, O: Pin, L: Pin, M: Pin, H: Pin> {
off: Input<'a, O>,
low: Flex<'a, L>,
medium: Flex<'a, M>,
high: Flex<'a, H>,
}
impl<'a, O: Pin, L: Pin, M: Pin, H: Pin> Controller<'a, O, L, M, H> {
pub fn new(
off: impl Peripheral<P = O> + 'a,
low: impl Peripheral<P = L> + 'a,
medium: impl Peripheral<P = M> + 'a,
high: impl Peripheral<P = H> + 'a,
) -> Self {
let off = Input::new(off, Pull::None);
let mut low = Flex::new(low);
low.set_low();
low.set_as_input();
let mut medium = Flex::new(medium);
medium.set_low();
medium.set_as_input();
let mut high = Flex::new(high);
high.set_low();
high.set_as_input();
Self {
off,
low,
medium,
high,
}
}
pub fn get_state(&mut self) -> (State, bool) {
let manual = self.off.is_high();
let state = match (self.low.is_low(), self.medium.is_low(), self.high.is_low()) {
(false, false, false) => State::Off,
(true, false, false) => State::Low,
(false, true, false) => State::Medium,
(false, false, true) => State::High,
(a, b, c) => {
// This happens if the user turns the knob, in this case we should turn off remote
// control
debug!("Unknown state: ({}, {}, {})", a, b, c);
self.set_state(State::Off);
State::Off
}
};
(state, manual)
}
pub fn set_state(&mut self, state: State) {
let manual = self.off.is_high();
if manual && state != State::Off {
warn!("Filter is manual controlled, cannot control remotely");
return;
}
debug!("Setting state: {}", state);
match state {
State::Off => {
self.low.set_as_input();
self.medium.set_as_input();
self.high.set_as_input();
}
State::Low => {
self.low.set_as_output();
self.low.set_drive_strength(embassy_rp::gpio::Drive::_12mA);
self.medium.set_as_input();
self.high.set_as_input();
}
State::Medium => {
self.low.set_as_input();
self.medium.set_as_output();
self.medium
.set_drive_strength(embassy_rp::gpio::Drive::_12mA);
self.high.set_as_input();
}
State::High => {
self.low.set_as_input();
self.medium.set_as_input();
self.high.set_as_output();
self.high.set_drive_strength(embassy_rp::gpio::Drive::_12mA);
}
}
}
pub async fn watch(&mut self) -> (State, bool) {
// Wait for change on any of the pins
select4(
self.off.wait_for_any_edge(),
self.low.wait_for_any_edge(),
self.medium.wait_for_any_edge(),
self.high.wait_for_any_edge(),
)
.await;
// Give it some time to stabilze
Timer::after(Duration::from_millis(500)).await;
if self.off.is_high() {
// If the filter is in manual mode, set the pico outputs to off
self.set_state(State::Off);
}
// Get the current state
self.get_state()
}
}
/// Get the cyw43 firmware blobs
///
/// # Safety
/// When building without `include_firmwares` make sure to flash the firmwares using the following
/// commands:
/// ```bash
/// probe-rs download firmware/43439A0.bin --format bin --chip RP2040 --base-address 0x101BE000
/// probe-rs download firmware/43439A0_clm.bin --format bin --chip RP2040 --base-address 0x101FE000
/// ```
unsafe fn get_firmware() -> (&'static [u8], &'static [u8]) {
cfg_if::cfg_if! {
if #[cfg(feature = "include_firmwares")] {
let fw = include_bytes!("../firmware/43439A0.bin");
let clm = include_bytes!("../firmware/43439A0_clm.bin");
(fw, clm)
} else {
// TODO: It would be nice if it could automatically get the correct size
extern "C" {
#[link_name = "__fw_start"]
static fw: [u8; 230321];
#[link_name = "__clm_start"]
static clm: [u8; 4752];
}
(&fw, &clm)
}
}
}
async fn wait_for_config(
stack: &'static Stack<cyw43::NetDriver<'static>>,
) -> embassy_net::StaticConfigV4 {
loop {
// We are essentially busy looping here since there is no Async API for this
if let Some(config) = stack.config_v4() {
return config;
}
yield_now().await;
}
}
#[embassy_executor::task]
async fn wifi_task(
runner: cyw43::Runner<
'static,
Output<'static, PIN_23>,
PioSpi<'static, PIN_25, PIO0, 0, DMA_CH1>,
>,
) -> ! {
runner.run().await
}
#[embassy_executor::task]
async fn net_task(stack: &'static Stack<cyw43::NetDriver<'static>>) -> ! {
stack.run().await
}
#[embassy_executor::main]
async fn main(spawner: Spawner) {
info!("Starting...");
let p = embassy_rp::init(Default::default());
// TODO: Ideally we use async flash
// This has issues with alignment right now
let flash = Flash::<_, _, FLASH_SIZE>::new_blocking(p.FLASH);
let flash = Mutex::new(RefCell::new(flash));
let config = FirmwareUpdaterConfig::from_linkerfile_blocking(&flash);
let mut aligned = AlignedBuffer([0; WRITE_SIZE]);
let updater = BlockingFirmwareUpdater::new(config, &mut aligned.0);
let mut updater = updater::Updater::new(updater, TOPIC_STATUS, VERSION, PUBLIC_SIGNING_KEY);
let mut controller = Controller::new(p.PIN_28, p.PIN_27, p.PIN_26, p.PIN_22);
let pwr = Output::new(p.PIN_23, Level::Low);
let cs = Output::new(p.PIN_25, Level::High);
let mut pio = Pio::new(p.PIO0, Irqs);
let spi = PioSpi::new(
&mut pio.common,
pio.sm0,
pio.irq0,
cs,
p.PIN_24,
p.PIN_29,
p.DMA_CH1,
);
let (fw, clm) = unsafe { get_firmware() };
let state = make_static!(cyw43::State::new());
let (net_device, mut control, runner) = cyw43::new(state, pwr, spi, fw).await;
spawner.spawn(wifi_task(runner)).unwrap();
control.init(clm).await;
control
.set_power_management(PowerManagementMode::PowerSave)
.await;
// Turn LED on while trying to connect
control.gpio_set(0, true).await;
let config = Config::dhcpv4(Default::default());
// Use the Ring Oscillator of the RP2040 as a source of true randomness to seed the
// cryptographically secure PRNG
let mut rng = StdRng::from_rng(&mut RoscRng).unwrap();
let stack = make_static!(Stack::new(
net_device,
config,
make_static!(StackResources::<6>::new()),
rng.next_u64(),
));
spawner.spawn(net_task(stack)).unwrap();
// Connect to wifi
loop {
match control
.join_wpa2(env!("WIFI_NETWORK"), env!("WIFI_PASSWORD"))
.await
{
Ok(_) => break,
Err(err) => {
info!("Failed to join with status = {}", err.status)
}
}
}
info!("Waiting for DHCP...");
let cfg = wait_for_config(stack).await;
info!("IP Address: {}", cfg.address.address());
let mut rx_buffer = [0; 1024];
let mut tx_buffer = [0; 1024];
let mut socket = TcpSocket::new(stack, &mut rx_buffer, &mut tx_buffer);
// socket.set_timeout(Some(Duration::from_secs(10)));
let url = Url::parse(env!("MQTT_ADDRESS")).unwrap();
debug!("MQTT URL: {}", url);
let ip = stack.dns_query(url.host(), DnsQueryType::A).await.unwrap()[0];
let addr = (ip, url.port_or_default());
debug!("MQTT ADDR: {}", addr);
while let Err(e) = socket.connect(addr).await {
warn!("Connect error: {:?}", e);
Timer::after(Duration::from_secs(1)).await;
}
info!("TCP Connected!");
let mut config = ClientConfig::new(
MqttVersion::MQTTv5,
// Use fast and simple PRNG to generate packet identifiers, there is no need for this to be
// cryptographically secure
SmallRng::from_rng(&mut RoscRng).unwrap(),
);
config.add_username(env!("MQTT_USERNAME"));
config.add_password(env!("MQTT_PASSWORD"));
config.add_max_subscribe_qos(QualityOfService::QoS1);
config.add_client_id(ID);
updater.add_will(&mut config);
let mut recv_buffer = [0; 1024];
let mut write_buffer = [0; 1024];
let mut client =
MqttClient::<_, 5, _>::new(socket, &mut write_buffer, &mut recv_buffer, config);
info!("Connecting to MQTT...");
client.connect_to_broker().await.unwrap();
info!("MQTT Connected!");
// We wait with marking as booted until everything is connected
client.subscribe_to_topic(TOPIC_UPDATE).await.unwrap();
client.subscribe_to_topic(TOPIC_SET).await.unwrap();
updater.ready(&mut client).await.unwrap();
// Turn LED off when connected
control.gpio_set(0, false).await;
let mut keep_alive = Ticker::every(Duration::from_secs(30));
loop {
let message = match select3(
keep_alive.next(),
client.receive_message(),
controller.watch(),
)
.await
{
Either3::First(_) => {
client.send_ping().await.unwrap();
None
}
Either3::Second(message) => match message {
Ok((TOPIC_UPDATE, url)) => {
let url: Vec<_, 256> = match Vec::from_slice(url) {
Ok(url) => url,
Err(_) => {
error!("URL is longer then buffer size");
continue;
}
};
let url = match from_utf8(&url) {
Ok(url) => url,
Err(err) => {
error!("Url is not valid utf-8 string: {}", Display2Format(&err));
continue;
}
};
let url = match Url::parse(url) {
Ok(url) => url,
Err(err) => {
error!("Failed to parse url: {}", err);
continue;
}
};
if let Err(err) = updater.update(url, stack, &mut rng, &mut client).await {
error!("Update failed: {}", err);
}
None
}
Ok((TOPIC_SET, message)) => {
let message: SetMessage = match serde_json_core::from_slice(message) {
Ok((message, _)) => message,
Err(_) => {
error!("Unable to parse set message");
continue;
}
};
controller.set_state(message.get_state());
Some(StateMessage::new(controller.get_state()))
}
Ok(_) => None,
Err(err) => {
error!("{}", err);
None
}
},
Either3::Third(state) => Some(StateMessage::new(state)),
};
if let Some(message) = message {
client
.send_message(TOPIC_BASE, &message.vec(), QualityOfService::QoS1, true)
.await
.unwrap();
}
}
}

3
wrapper.sh Executable file
View File

@ -0,0 +1,3 @@
#!/bin/bash
probe-run --chip RP2040 --log-format="{L} {s}
└─ [{t}] {m} @ {F}:{l}" $@