initial commit
This commit is contained in:
commit
2ef1b13bbe
9 changed files with 311 additions and 0 deletions
16
.cargo/config.toml
Normal file
16
.cargo/config.toml
Normal 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
5
.gitignore
vendored
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
/.vscode
|
||||||
|
/.embuild
|
||||||
|
/target
|
||||||
|
/Cargo.lock
|
||||||
|
/espflash_ports.toml
|
33
Cargo.toml
Normal file
33
Cargo.toml
Normal 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
97
README.md
Normal 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
3
build.rs
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
fn main() {
|
||||||
|
embuild::espidf::sysenv::output();
|
||||||
|
}
|
2
rust-toolchain.toml
Normal file
2
rust-toolchain.toml
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
[toolchain]
|
||||||
|
channel = "esp"
|
10
sdkconfig.defaults
Normal file
10
sdkconfig.defaults
Normal 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
5
src/config.rs
Normal 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
140
src/main.rs
Normal 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))
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue