commit 2ef1b13bbeeda00cb52998a3a627ca87f8aae1b5 Author: Brent Schroeter Date: Sat Sep 6 02:35:57 2025 -0700 initial commit diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..dcd155a --- /dev/null +++ b/.cargo/config.toml @@ -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" + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2b0ff6c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/.vscode +/.embuild +/target +/Cargo.lock +/espflash_ports.toml diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3007457 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "flipflop" +version = "0.1.0" +authors = ["Brent Schroeter "] +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" diff --git a/README.md b/README.md new file mode 100644 index 0000000..3bdd844 --- /dev/null +++ b/README.md @@ -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/' +[2025-09-06T05:46:02Z INFO ] Connecting... +Error: × Failed to open serial port /dev/ + ╰─▶ 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/ +``` + +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. diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..112ec3f --- /dev/null +++ b/build.rs @@ -0,0 +1,3 @@ +fn main() { + embuild::espidf::sysenv::output(); +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..a2f5ab5 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "esp" diff --git a/sdkconfig.defaults b/sdkconfig.defaults new file mode 100644 index 0000000..c25b89d --- /dev/null +++ b/sdkconfig.defaults @@ -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 diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..939a4ef --- /dev/null +++ b/src/config.rs @@ -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"; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..d5854e0 --- /dev/null +++ b/src/main.rs @@ -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

+ 'static, + sysloop: EspSystemEventLoop, +) -> Result>> { + 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)) +}