initial commit

This commit is contained in:
Brent Schroeter 2025-09-06 02:35:57 -07:00
commit 2ef1b13bbe
9 changed files with 311 additions and 0 deletions

16
.cargo/config.toml Normal file
View file

@ -0,0 +1,16 @@
[build]
target = "xtensa-esp32-espidf"
[target.xtensa-esp32-espidf]
linker = "ldproxy"
runner = "espflash flash --monitor"
rustflags = [ "--cfg", "espidf_time64"]
[unstable]
build-std = ["std", "panic_abort"]
[env]
MCU="esp32"
# Note: this variable is not used by the pio builder (`cargo build --features pio`)
ESP_IDF_VERSION = "v5.3.3"

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
/.vscode
/.embuild
/target
/Cargo.lock
/espflash_ports.toml

33
Cargo.toml Normal file
View file

@ -0,0 +1,33 @@
[package]
name = "flipflop"
version = "0.1.0"
authors = ["Brent Schroeter <contact@brentsch.com>"]
edition = "2021"
resolver = "2"
rust-version = "1.77"
[[bin]]
name = "flipflop"
harness = false # do not use the built in cargo test harness -> resolve rust-analyzer errors
[profile.release]
opt-level = "s"
[profile.dev]
debug = true
opt-level = "z"
[features]
default = []
experimental = ["esp-idf-svc/experimental"]
[dependencies]
log = "0.4"
esp-idf-svc = "0.51"
anyhow = { version = "1.0.99", features = ["backtrace"] }
chrono = { version = "0.4.41", default-features = false, features = ["now"] }
chrono-tz = "0.10.4"
[build-dependencies]
embuild = "0.33"

97
README.md Normal file
View file

@ -0,0 +1,97 @@
# Flip-Flop
Firmware for a daily timer switch powered by the
[QT Py ESP32 Pico](https://learn.adafruit.com/adafruit-qt-py-esp32-pico/overview).
This application uses NTP to synchronize the microcontroller clock, allowing
the system to recover automatically after losing power, so long as it remains
within range of a known WiFi access point.
## Configuration
Configuration values are hard-coded in `src/config.rs`. Previously,
configuration was handled by [toml-cfg](https://crates.io/crates/toml-cfg/),
but hard-coding the values accomplishes the same thing with fewer moving parts.
## Toolchain Installation and Usage
Refer to the
[Rust on ESP book](https://docs.espressif.com/projects/rust/book/introduction.html)
for installation of the development toolchain.
Development was completed on a system running Fedora 42, which has some
dependencies which diverge from the official documentation.
### Detailed Instructions for Fedora 42
#### Installing espup
Espressif has documentation for [setting up a development
environment](https://docs.espressif.com/projects/rust/book/installation/index.html);
however, these do not work out of the box with Fedora 42. In addition to installing
Rust--
```sh
sudo dnf install rustup
rustup-init
```
--we must install several dependencies before `espup` will compile in the next step (note that the perl packages are case-sensitive):
```sh
sudo dnf install git wget flex bison gperf python3 python3-pip cmake ninja-build ccache libffi-devel openssl-devel dfu-util libusb1 perl-FindBin perl-IPC-Cmd perl-File-Compare
pip install virtualenv
```
Now, we can run:
```sh
cargo install espup --locked
cargo install espflash
cargo install ldproxy # Required for std development
espup install
```
This will install the relevant Espressif Rust, LLVM, and GCC tools, as well as generating an activation script in `~/export-esp.sh`.
If you want to use this toolchain by default, you may want to add `source ~/export-esp.sh` to your `.profile`.
#### Connecting and Flashing the MCU
Once the development board (or serial console) is connected to the computer via USB, the example project may be flashed with:
```sh
cargo run
```
This compiles the project and shells out automatically to `espflash` to flash and establish a serial console connection to the board.
In my case, it identified the correct device automatically. However in some cases, the device exposed to the file system may
have restrictive permissions applied, which will cause an error to the effect of:
```
[2025-09-06T05:46:02Z INFO ] Serial port: '/dev/<DEVICE NAME>'
[2025-09-06T05:46:02Z INFO ] Connecting...
Error: × Failed to open serial port /dev/<DEVICE NAME>
╰─▶ Error while connecting to device
```
Granting read/write permissions to all users, should be sufficient to resolve the above issue (note that this is required each time the device is physically reconnected):
```sh
chmod a+rw /dev/<DEVICE NAME>
```
The port will be saved to `espflash_ports.toml`. For further information, refer to the [`espflash` documentation](https://github.com/esp-rs/espflash/blob/1daf446a0a553d23309e77c8781679ca25fc007a/espflash/README.md).
When successful, `espflash` will identify the connected MCU and its specifications.
```
[2025-09-06T05:47:07Z INFO ] Serial port: '/dev/ttyACM0'
[2025-09-06T05:47:07Z INFO ] Connecting...
[2025-09-06T05:47:08Z INFO ] Using flash stub
Chip type: esp32 (revision v3.0)
Crystal frequency: 40 MHz
Flash size: 8MB
Features: WiFi, BT, Dual Core, 240MHz, Embedded Flash, Embedded PSRAM, VRef calibration in efuse, Coding Scheme None
```
It will then spend several seconds flashing, booting, and finally executing the project on the ESP32 system.

3
build.rs Normal file
View file

@ -0,0 +1,3 @@
fn main() {
embuild::espidf::sysenv::output();
}

2
rust-toolchain.toml Normal file
View file

@ -0,0 +1,2 @@
[toolchain]
channel = "esp"

10
sdkconfig.defaults Normal file
View file

@ -0,0 +1,10 @@
# Rust often needs a bit of an extra main task stack size compared to C (the default is 3K)
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8000
# Use this to set FreeRTOS kernel tick frequency to 1000 Hz (100 Hz by default).
# This allows to use 1 ms granularity for thread sleeps (10 ms by default).
#CONFIG_FREERTOS_HZ=1000
# Workaround for https://github.com/espressif/esp-idf/issues/7631
#CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=n
#CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_FULL=n

5
src/config.rs Normal file
View file

@ -0,0 +1,5 @@
pub(crate) const SNTP_SERVER: &'static str = "time.nist.gov";
pub(crate) const T_ON: &'static str = "17:00";
pub(crate) const T_OFF: &'static str = "09:00";
pub(crate) const WIFI_SSID: &'static str = "Example";
pub(crate) const WIFI_PASS: &'static str = "guest";

140
src/main.rs Normal file
View file

@ -0,0 +1,140 @@
use std::default::Default;
use anyhow::{bail, Result};
use chrono::{NaiveTime, Utc};
use chrono_tz::US::Pacific;
use esp_idf_svc::{
eventloop::EspSystemEventLoop,
hal::{delay::FreeRtos, gpio::*, modem::Modem, peripheral::Peripheral, prelude::Peripherals},
sntp::{EspSntp, SntpConf, SyncMode, SyncStatus},
wifi::{AuthMethod, BlockingWifi, ClientConfiguration, Configuration, EspWifi},
};
use log::info;
mod config;
const SNTP_STATUS_POLL_INTVL_MS: u32 = 2000;
const CONTROL_LOOP_INTVL_MS: u32 = 60000;
fn main() -> Result<()> {
// It is necessary to call this function once. Otherwise some patches to
// the runtime implemented by esp-idf-sys might not link properly. Refer
// to https://github.com/esp-rs/esp-idf-template/issues/71.
esp_idf_svc::sys::link_patches();
// Bind the log crate to the ESP Logging facilities
esp_idf_svc::log::EspLogger::initialize_default();
let t_on = NaiveTime::parse_from_str(config::T_ON, "%H:%M")?;
let t_off = NaiveTime::parse_from_str(config::T_OFF, "%H:%M")?;
if t_on == t_off {
bail!("t_on and t_off must have distinct values");
}
let peripherals = Peripherals::take()?;
let mut switch = PinDriver::output(peripherals.pins.gpio7)?;
switch.set_low()?;
let sysloop = EspSystemEventLoop::take()?;
// Dropping EspWifi shuts down the connection, so this variable must remain
// in scope.
let _wifi = init_wifi(peripherals.modem, sysloop);
// SNTP client will continue running in the background until this value is
// dropped.
let ntp = EspSntp::new(&SntpConf {
servers: [config::SNTP_SERVER],
sync_mode: SyncMode::Smooth,
..Default::default()
})?;
// get_sync_status() returns "Completed" one time and then reverts to
// "Reset" on subsequent calls.
while ntp.get_sync_status() != SyncStatus::Completed {
info!("Waiting for SNTP to sync...");
FreeRtos::delay_ms(SNTP_STATUS_POLL_INTVL_MS);
}
// Main control loop:
loop {
let now = Utc::now().with_timezone(&Pacific);
info!("Current time: {}", now);
let t = now.time();
let active = match (t_on < t_off, t > t_on, t > t_off) {
// Active period falls within single day, and t falls between t_on
// and t_off.
(true, true, false) => true,
// Active period crosses midnight, and t does not fall between
// t_off and t_on.
(false, true, true) => true,
(false, false, false) => true,
// All other cases.
_ => false,
};
if active {
switch.set_high()?;
} else {
switch.set_low()?;
}
// TODO: enter low power mode ("light sleep" or "deep sleep") instead
// of waiting in normal mode.
FreeRtos::delay_ms(CONTROL_LOOP_INTVL_MS);
}
}
/// Start WiFi module and connect to access point. Returns an error if either
/// of WiFi startup or connection fail.
fn init_wifi(
modem: impl Peripheral<P = Modem> + 'static,
sysloop: EspSystemEventLoop,
) -> Result<Box<EspWifi<'static>>> {
let auth_method = AuthMethod::WPA2Personal;
let mut esp_wifi = EspWifi::new(modem, sysloop.clone(), None)?;
let mut wifi = BlockingWifi::wrap(&mut esp_wifi, sysloop)?;
wifi.set_configuration(&Configuration::Client(ClientConfiguration::default()))?;
info!("Starting wifi...");
wifi.start()?;
info!("Scanning...");
let ap_infos = wifi.scan()?;
let ours = ap_infos.into_iter().find(|a| a.ssid == config::WIFI_SSID);
let channel = if let Some(ours) = ours {
info!(
"Found configured access point {} on channel {}",
config::WIFI_SSID, ours.channel
);
Some(ours.channel)
} else {
info!(
"Configured access point {} not found during scanning, will go with unknown channel",
config::WIFI_SSID
);
None
};
wifi.set_configuration(&Configuration::Client(ClientConfiguration {
ssid: config::WIFI_SSID
.try_into()
.expect("Could not parse the given SSID into WiFi config"),
password: config::WIFI_PASS
.try_into()
.expect("Could not parse the given password into WiFi config"),
channel,
auth_method,
..Default::default()
}))?;
info!("Connecting wifi...");
wifi.connect()?;
info!("Waiting for DHCP lease...");
wifi.wait_netif_up()?;
let ip_info = wifi.wifi().sta_netif().get_ip_info()?;
info!("Wifi DHCP info: {:?}", ip_info);
Ok(Box::new(esp_wifi))
}