diff --git a/rust/all.sh b/rust/all.sh new file mode 100755 index 0000000..7f046eb --- /dev/null +++ b/rust/all.sh @@ -0,0 +1,12 @@ +rm ./src/webserver/index.html.gz +rm ./src/webserver/bundle.js.gz +set -e +cd ./src_webpack/ +npx webpack build +cp index.html.gz ../src/webserver/index.html.gz +cp bundle.js.gz ../src/webserver/bundle.js.gz +cd ../ + +cargo build --release +espflash save-image --bootloader bootloader.bin --partition-table partitions.csv --chip esp32c6 target/riscv32imac-unknown-none-elf/release/plant-ctrl2 image.bin +espflash flash --monitor --bootloader bootloader.bin --chip esp32c6 --baud 921600 --partition-table partitions.csv target/riscv32imac-unknown-none-elf/release/plant-ctrl2 diff --git a/rust/flash.sh b/rust/flash.sh index 077d529..07ff4fb 100755 --- a/rust/flash.sh +++ b/rust/flash.sh @@ -7,5 +7,5 @@ cp index.html.gz ../src/webserver/index.html.gz cp bundle.js.gz ../src/webserver/bundle.js.gz cd ../ -cargo build +cargo build --release espflash flash --monitor --bootloader bootloader.bin --chip esp32c6 --baud 921600 --partition-table partitions.csv target/riscv32imac-unknown-none-elf/release/plant-ctrl2 diff --git a/rust/src/fat_error.rs b/rust/src/fat_error.rs index a98e463..0f89e12 100644 --- a/rust/src/fat_error.rs +++ b/rust/src/fat_error.rs @@ -65,6 +65,9 @@ pub enum FatError { CanBusError { error: EspTwaiError, }, + SNTPError { + error: sntpc::Error, + }, } pub type FatResult = Result; @@ -96,6 +99,7 @@ impl fmt::Display for FatError { FatError::CanBusError { error } => { write!(f, "CanBusError {:?}", error) } + FatError::SNTPError { error } => write!(f, "SNTPError {:?}", error), } } } @@ -299,10 +303,16 @@ impl From> for FatError { } } -impl From for FatError{ +impl From for FatError { fn from(value: NorFlashErrorKind) -> Self { FatError::String { - error: value.to_string() + error: value.to_string(), } } } + +impl From for FatError { + fn from(value: sntpc::Error) -> Self { + FatError::SNTPError { error: value } + } +} diff --git a/rust/src/hal/esp.rs b/rust/src/hal/esp.rs index 99dc322..755b62d 100644 --- a/rust/src/hal/esp.rs +++ b/rust/src/hal/esp.rs @@ -1,6 +1,6 @@ use crate::bail; use crate::config::{NetworkConfig, PlantControllerConfig}; -use crate::hal::{get_next_slot, PLANT_COUNT, TIME_ACCESS}; +use crate::hal::{get_current_slot_and_fix_ota_data, PLANT_COUNT, TIME_ACCESS}; use crate::log::{LogMessage, LOG_ACCESS}; use chrono::{DateTime, Utc}; use serde::Serialize; @@ -19,10 +19,10 @@ use embassy_net::{DhcpConfig, Ipv4Cidr, Runner, Stack, StackResources, StaticCon use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; use embassy_sync::mutex::{Mutex, MutexGuard}; use embassy_sync::once_lock::OnceLock; -use embassy_time::{Duration, Timer}; +use embassy_time::{Duration, Timer, WithTimeout}; use embedded_storage::nor_flash::{check_erase, NorFlash, ReadNorFlash}; use esp_bootloader_esp_idf::ota::OtaImageState::Valid; -use esp_bootloader_esp_idf::ota::{Ota, OtaImageState}; +use esp_bootloader_esp_idf::ota::{Ota, OtaImageState, Slot}; use esp_bootloader_esp_idf::partitions::FlashRegion; use esp_hal::gpio::{Input, RtcPinWithResistors}; use esp_hal::rng::Rng; @@ -128,7 +128,10 @@ pub struct Esp<'a> { pub wake_gpio1: esp_hal::peripherals::GPIO1<'static>, pub ota: Ota<'static, FlashStorage>, - pub ota_next: &'static mut FlashRegion<'static, FlashStorage>, + pub ota_target: &'static mut FlashRegion<'static, FlashStorage>, + pub current: Slot, + pub slot0_state: OtaImageState, + pub slot1_state: OtaImageState, } // SAFETY: On this target we never move Esp across OS threads; the firmware runs single-core @@ -213,43 +216,15 @@ impl Esp<'_> { Ok((buf, read)) } - pub(crate) fn get_current_ota_slot(&mut self) -> String { - match get_next_slot(&mut self.ota) { - Ok(slot) => { - format!("{:?}", slot.next()) - } - Err(err) => { - format!("{:?}", err) - } - } - } - - pub(crate) fn get_ota_state(&mut self) -> String { - match self.ota.current_ota_state() { - Ok(state) => { - format!("{:?}", state) - } - Err(err) => { - format!("{:?}", err) - } - } - } - pub(crate) async fn write_ota(&mut self, offset: u32, buf: &[u8]) -> Result<(), FatError> { - if self.ota.current_ota_state() == Ok(OtaImageState::Invalid) { - bail!("Invalid OTA state, refusing ota write") - } - if self.ota.current_ota_state() == Ok(OtaImageState::Undefined) { - bail!("Invalid OTA state, refusing ota write") - } - let _ = check_erase(self.ota_next, offset, offset + 4096); - self.ota_next.erase(offset, offset + 4096)?; + let _ = check_erase(self.ota_target, offset, offset + 4096); + self.ota_target.erase(offset, offset + 4096)?; let mut temp = vec![0; buf.len()]; let read_back = temp.as_mut_slice(); //change to nor flash, align writes! - self.ota_next.write(offset, buf)?; - self.ota_next.read(offset, read_back)?; + self.ota_target.write(offset, buf)?; + self.ota_target.read(offset, read_back)?; if buf != read_back { info!("Expected {:?} but got {:?}", buf, read_back); bail!( @@ -261,23 +236,16 @@ impl Esp<'_> { } pub(crate) async fn finalize_ota(&mut self) -> Result<(), FatError> { - if self.ota.current_ota_state() == Ok(OtaImageState::Invalid) { - bail!("Invalid OTA state, refusing ota write") - } - if self.ota.current_ota_state() == Ok(OtaImageState::Undefined) { - bail!("Invalid OTA state, refusing ota write") - } - - let current_state = self.ota.current_ota_state()?; - info!("current state {:?}", current_state); - let next_slot = get_next_slot(&mut self.ota)?; - info!("current slot {:?}", next_slot.next()); - if current_state == OtaImageState::PendingVerify { - info!("verifying ota image from pending"); + let current = self.ota.current_slot()?; + if self.ota.current_ota_state()? != OtaImageState::Valid { + info!( + "Validating current slot {:?} as it was able to ota", + current + ); self.ota.set_current_ota_state(Valid)?; } - self.ota.set_current_slot(next_slot)?; + self.ota.set_current_slot(current.next())?; info!("switched slot"); self.ota.set_current_ota_state(OtaImageState::New)?; info!("switched state for new partition"); @@ -327,16 +295,19 @@ impl Esp<'_> { let mut counter = 0; loop { let addr: IpAddr = ntp_addrs[0].into(); - let result = get_time(SocketAddr::from((addr, 123)), &socket, context).await; + let timeout = get_time(SocketAddr::from((addr, 123)), &socket, context) + .with_timeout(Duration::from_millis((_max_wait_ms / 10) as u64)) + .await; - match result { - Ok(time) => { + match timeout { + Ok(result) => { + let time = result?; info!("Time: {:?}", time); return DateTime::from_timestamp(time.seconds as i64, 0) .context("Could not convert Sntp result"); } - Err(e) => { - warn!("Error: {:?}", e); + Err(err) => { + warn!("sntp timeout, retry: {:?}", err); counter += 1; if counter > 10 { bail!("Failed to get time from NTP server"); diff --git a/rust/src/hal/mod.rs b/rust/src/hal/mod.rs index e3b3a44..caa7de4 100644 --- a/rust/src/hal/mod.rs +++ b/rust/src/hal/mod.rs @@ -9,7 +9,6 @@ mod v3_shift_register; mod v4_hal; mod v4_sensor; mod water; - use crate::alloc::string::ToString; use crate::hal::rtc::{DS3231Module, RTCModuleInteraction}; use esp_hal::peripherals::Peripherals; @@ -44,6 +43,7 @@ use esp_hal::peripherals::GPIO8; use esp_hal::peripherals::TWAI0; use crate::{ + bail, config::{BatteryBoardVersion, BoardVersion, PlantControllerConfig}, hal::{ battery::{BatteryInteraction, NoBatteryMonitor}, @@ -83,9 +83,11 @@ use crate::hal::water::TankSensor; use crate::log::LOG_ACCESS; use embassy_sync::mutex::Mutex; use embassy_sync::once_lock::OnceLock; +use embedded_storage::nor_flash::ReadNorFlash; use esp_alloc as _; use esp_backtrace as _; -use esp_bootloader_esp_idf::ota::{Ota, OtaImageState, Slot}; +use esp_bootloader_esp_idf::ota::{Ota, OtaImageState}; +use esp_bootloader_esp_idf::ota::{Slot as ota_slot, Slot}; use esp_hal::delay::Delay; use esp_hal::i2c::master::{BusTimeout, Config, I2c}; use esp_hal::pcnt::unit::Unit; @@ -101,12 +103,15 @@ use esp_wifi::{init, EspWifiController}; use littlefs2::fs::{Allocation, Filesystem as lfs2Filesystem}; use littlefs2::object_safe::DynStorage; use log::{error, info, warn}; +use portable_atomic::AtomicBool; pub static TIME_ACCESS: OnceLock> = OnceLock::new(); //Only support for 8 right now! pub const PLANT_COUNT: usize = 8; +pub static PROGRESS_ACTIVE: AtomicBool = AtomicBool::new(false); + const TANK_MULTI_SAMPLE: usize = 11; pub static I2C_DRIVER: OnceLock< embassy_sync::blocking_mutex::Mutex>>, @@ -148,6 +153,9 @@ pub trait BoardInteraction<'a> { async fn get_mptt_current(&mut self) -> Result; async fn progress(&mut self, counter: u32) { + // Indicate progress is active to suppress default wait_infinity blinking + crate::hal::PROGRESS_ACTIVE.store(true, core::sync::atomic::Ordering::Relaxed); + let current = counter % PLANT_COUNT as u32; for led in 0..PLANT_COUNT { if let Err(err) = self.fault(led, current == led as u32).await { @@ -165,6 +173,9 @@ pub trait BoardInteraction<'a> { } } let _ = self.general_fault(false).await; + + // Reset progress active flag so wait_infinity can resume blinking + crate::hal::PROGRESS_ACTIVE.store(false, core::sync::atomic::Ordering::Relaxed); } } @@ -303,10 +314,6 @@ impl PlantHal { let pt = esp_bootloader_esp_idf::partitions::read_partition_table(storage_ota, tablebuffer)?; - // List all partitions - this is just FYI - for i in 0..pt.len() { - info!("{:?}", pt.get_partition(i)); - } let ota_data = mk_static!( PartitionEntry, pt.find_partition(esp_bootloader_esp_idf::partitions::PartitionType::Data( @@ -320,13 +327,18 @@ impl PlantHal { ota_data.as_embedded_storage(storage_ota) ); + let state_0 = ota_state(ota_slot::Slot0, ota_data); + let state_1 = ota_state(ota_slot::Slot1, ota_data); let mut ota = Ota::new(ota_data)?; + let running = get_current_slot_and_fix_ota_data(&mut ota, state_0, state_1)?; + let target = running.next(); - let state = ota.current_ota_state().unwrap_or_default(); - info!("Current OTA state: {:?}", state); - let next_slot = get_next_slot(&mut ota)?; - info!("Next OTA slot: {:?}", next_slot); - let ota_next = match next_slot { + info!("Currently running OTA slot: {:?}", running); + info!("Slot0 state: {:?}", state_0); + info!("Slot1 state: {:?}", state_1); + + //obtain current_state and next_state here! + let ota_target = match target { Slot::None => { panic!("No OTA slot active?"); } @@ -342,11 +354,11 @@ impl PlantHal { .context("Partition table invalid no ota1")?, }; - let ota_next = mk_static!(PartitionEntry, ota_next); + let ota_target = mk_static!(PartitionEntry, ota_target); let storage_ota = mk_static!(FlashStorage, FlashStorage::new()); - let ota_next = mk_static!( + let ota_target = mk_static!( FlashRegion, - ota_next.as_embedded_storage(storage_ota) + ota_target.as_embedded_storage(storage_ota) ); let data_partition = pt @@ -391,7 +403,10 @@ impl PlantHal { boot_button, wake_gpio1, ota, - ota_next, + ota_target, + current: running, + slot0_state: state_0, + slot1_state: state_1, }; //init,reset rtc memory depending on cause @@ -573,29 +588,75 @@ impl PlantHal { } } -fn get_next_slot(ota: &mut Ota) -> Result { - let next_slot = { - let state = ota.current_ota_state().unwrap_or_default(); - let current = ota.current_slot()?; - match state { - OtaImageState::New => current.next(), - OtaImageState::PendingVerify => current.next(), - OtaImageState::Valid => current.next(), +fn ota_state(slot: ota_slot, ota_data: &mut FlashRegion) -> OtaImageState { + // Read and log OTA states for both slots before constructing Ota + // Each OTA select entry is 32 bytes: [seq:4][label:20][state:4][crc:4] + // Offsets within the OTA data partition: slot0 @ 0x0000, slot1 @ 0x1000 + if slot == ota_slot::None { + return OtaImageState::Undefined; + } + let mut slot_buf = [0u8; 32]; + if slot == ota_slot::Slot0 { + let _ = ota_data.read(0x0000, &mut slot_buf); + } else { + let _ = ota_data.read(0x1000, &mut slot_buf); + } + let raw_state = u32::from_le_bytes(slot_buf[24..28].try_into().unwrap_or([0xff; 4])); + let state0 = OtaImageState::try_from(raw_state).unwrap_or(OtaImageState::Undefined); + state0 +} + +fn get_current_slot_and_fix_ota_data( + ota: &mut Ota, + state0: OtaImageState, + state1: OtaImageState, +) -> Result { + let state = ota.current_ota_state().unwrap_or_default(); + let swap = match state { + OtaImageState::Invalid => true, + OtaImageState::Aborted => true, + OtaImageState::Undefined => { + info!("Undefined image in current slot, bootloader wrong?"); + false + } + _ => false, + }; + let current = ota.current_slot()?; + if swap { + let other = match current { + ota_slot::Slot0 => state1, + ota_slot::Slot1 => state0, + _ => OtaImageState::Invalid, + }; + + match other { OtaImageState::Invalid => { - //we actually booted other slot, than partition table assumes - current + bail!( + "cannot recover slot, as both slots in invalid state {:?} {:?} {:?}", + current, + state0, + state1 + ); } OtaImageState::Aborted => { - //we actually booted other slot, than partition table assumes - current - } - OtaImageState::Undefined => { - //missing bootloader? - current.next() + bail!( + "cannot recover slot, as both slots in invalid state {:?} {:?} {:?}", + current, + state0, + state1 + ); } + _ => {} } + info!( + "Current slot has state {:?} other state has {:?} swapping", + state, other + ); + ota.set_current_slot(current.next())?; + //we actually booted other slot, than partition table assumes + return Ok(ota.current_slot()?); }; - Ok(next_slot) + Ok(current) } pub async fn esp_time() -> DateTime { diff --git a/rust/src/main.rs b/rust/src/main.rs index ff2e065..18864e2 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -17,6 +17,7 @@ use crate::config::{NetworkConfig, PlantConfig}; use crate::fat_error::FatResult; use crate::hal::esp::MQTT_STAY_ALIVE; use crate::hal::{esp_time, TIME_ACCESS}; +use crate::hal::PROGRESS_ACTIVE; use crate::log::{log, LOG_ACCESS}; use crate::tank::{determine_tank_state, TankError, TankState, WATER_FROZEN_THRESH}; use crate::webserver::http_server; @@ -822,18 +823,9 @@ async fn publish_firmware_info( let esp = board.board_hal.get_esp(); let _ = esp.mqtt_publish("/firmware/address", ip_address).await; let _ = esp - .mqtt_publish("/firmware/githash", &version.git_hash) - .await; - let _ = esp - .mqtt_publish("/firmware/buildtime", &version.build_time) + .mqtt_publish("/firmware/state", format!("{:?}", &version).as_str()) .await; let _ = esp.mqtt_publish("/firmware/last_online", timezone_time); - let state = esp.get_ota_state(); - let _ = esp.mqtt_publish("/firmware/ota_state", &state).await; - let slot = esp.get_current_ota_slot(); - let _ = esp - .mqtt_publish("/firmware/ota_slot", &format!("slot{slot}")) - .await; let _ = esp.mqtt_publish("/state", "online").await; } macro_rules! mk_static { @@ -995,41 +987,48 @@ async fn wait_infinity( let mut board = BOARD_ACCESS.get().await.lock().await; update_charge_indicator(&mut board).await; - match wait_type { - WaitType::MissingConfig => { - // Keep existing behavior: circular filling pattern - led_count %= 8; - led_count += 1; - for i in 0..8 { - let _ = board.board_hal.fault(i, i < led_count).await; - } - } - WaitType::ConfigButton => { - // Alternating pattern: 1010 1010 -> 0101 0101 - pattern_step = (pattern_step + 1) % 2; - for i in 0..8 { - let _ = board.board_hal.fault(i, (i + pattern_step) % 2 == 0).await; - } - } - WaitType::MqttConfig => { - // Moving dot pattern - pattern_step = (pattern_step + 1) % 8; - for i in 0..8 { - let _ = board.board_hal.fault(i, i == pattern_step).await; + // Skip default blink code when a progress display is active + if !PROGRESS_ACTIVE.load(Ordering::Relaxed) { + match wait_type { + WaitType::MissingConfig => { + // Keep existing behavior: circular filling pattern + led_count %= 8; + led_count += 1; + for i in 0..8 { + let _ = board.board_hal.fault(i, i < led_count).await; + } + } + WaitType::ConfigButton => { + // Alternating pattern: 1010 1010 -> 0101 0101 + pattern_step = (pattern_step + 1) % 2; + for i in 0..8 { + let _ = board.board_hal.fault(i, (i + pattern_step) % 2 == 0).await; + } + } + WaitType::MqttConfig => { + // Moving dot pattern + pattern_step = (pattern_step + 1) % 8; + for i in 0..8 { + let _ = board.board_hal.fault(i, i == pattern_step).await; + } } } + board.board_hal.general_fault(true).await; } - board.board_hal.general_fault(true).await; } Timer::after_millis(delay).await; { let mut board = BOARD_ACCESS.get().await.lock().await; - board.board_hal.general_fault(false).await; - // Clear all LEDs - for i in 0..8 { - let _ = board.board_hal.fault(i, false).await; + // Skip clearing LEDs when progress is active to avoid interrupting the progress display + if !PROGRESS_ACTIVE.load(Ordering::Relaxed) { + board.board_hal.general_fault(false).await; + + // Clear all LEDs + for i in 0..8 { + let _ = board.board_hal.fault(i, false).await; + } } } @@ -1096,14 +1095,12 @@ async fn get_version( let hash = &env!("VERGEN_GIT_SHA")[0..8]; let board = board.board_hal.get_esp(); - - let ota_slot = board.get_current_ota_slot(); - let ota_state = board.get_ota_state(); VersionInfo { git_hash: branch + "@" + hash, build_time: env!("VERGEN_BUILD_TIMESTAMP").to_owned(), - partition: ota_slot, - ota_state, + current: format!("{:?}", board.current), + slot0_state: format!("{:?}", board.slot0_state), + slot1_state: format!("{:?}", board.slot1_state), } } @@ -1111,6 +1108,7 @@ async fn get_version( struct VersionInfo { git_hash: String, build_time: String, - partition: String, - ota_state: String, + current: String, + slot0_state: String, + slot1_state: String, } diff --git a/rust/src/webserver/mod.rs b/rust/src/webserver/mod.rs index 3a9eb7b..2d5c16f 100644 --- a/rust/src/webserver/mod.rs +++ b/rust/src/webserver/mod.rs @@ -240,34 +240,6 @@ pub async fn http_server(reboot_now: Arc, stack: Stack<'static>) { info!("Webserver started and waiting for connections"); //TODO https if mbed_esp lands - - // server - // .fn_handler("/ota", Method::Post, |request| { - // handle_error_to500(request, ota) - // }) - // .unwrap(); - // server - // .fn_handler("/ota", Method::Options, |request| { - // cors_response(request, 200, "") - // }) - // .unwrap(); - // let reboot_now_for_reboot = reboot_now.clone(); - // server - // .fn_handler("/reboot", Method::Post, move |_| { - // BOARD_ACCESS - // .lock() - // .unwrap() - // .board_hal - // .get_esp() - // .set_restart_to_conf(true); - // reboot_now_for_reboot.store(true, std::sync::atomic::Ordering::Relaxed); - // anyhow::Ok(()) - // }) - // .unwrap(); - // - // unsafe { vTaskDelay(1) }; - // - // server } async fn handle_json<'a, T, const N: usize>( diff --git a/rust/src/webserver/ota.rs b/rust/src/webserver/ota.rs index 8c63935..36201e8 100644 --- a/rust/src/webserver/ota.rs +++ b/rust/src/webserver/ota.rs @@ -30,9 +30,6 @@ where Method::Post => { let mut offset = 0_usize; let mut chunk = 0; - - // Erase only a single 4K block right before writing into it. - // The first block will be erased when offset == 0 below. loop { let buf = read_up_to_bytes_from_request(conn, Some(4096)).await?; if buf.len() == 0 { diff --git a/rust/src_webpack/src/api.ts b/rust/src_webpack/src/api.ts index 9067e27..915cdfe 100644 --- a/rust/src_webpack/src/api.ts +++ b/rust/src_webpack/src/api.ts @@ -157,9 +157,9 @@ export interface Moistures { export interface VersionInfo { git_hash: string, build_time: string, - partition: string, - ota_state: string - + current: string, + slot0_state: string, + slot1_state: string, } export interface BatteryState { diff --git a/rust/src_webpack/src/ota.html b/rust/src_webpack/src/ota.html index 2c184c3..0d18370 100644 --- a/rust/src_webpack/src/ota.html +++ b/rust/src_webpack/src/ota.html @@ -1,18 +1,21 @@
@@ -28,12 +31,16 @@
- Partition: - + Partition: +
- Status: - + State0: + +
+
+ State1: +
diff --git a/rust/src_webpack/src/ota.ts b/rust/src_webpack/src/ota.ts index d1562fe..c407932 100644 --- a/rust/src_webpack/src/ota.ts +++ b/rust/src_webpack/src/ota.ts @@ -1,4 +1,4 @@ -import { Controller } from "./main"; +import {Controller} from "./main"; import {VersionInfo} from "./api"; export class OTAView { @@ -6,19 +6,22 @@ export class OTAView { readonly firmware_buildtime: HTMLDivElement; readonly firmware_githash: HTMLDivElement; readonly firmware_partition: HTMLDivElement; - readonly firmware_state: HTMLDivElement; + readonly firmware_state0: HTMLDivElement; + readonly firmware_state1: HTMLDivElement; constructor(controller: Controller) { (document.getElementById("firmwareview") as HTMLElement).innerHTML = require("./ota.html") - let test = document.getElementById("test") as HTMLButtonElement; + let test = document.getElementById("test") as HTMLButtonElement; this.firmware_buildtime = document.getElementById("firmware_buildtime") as HTMLDivElement; this.firmware_githash = document.getElementById("firmware_githash") as HTMLDivElement; this.firmware_partition = document.getElementById("firmware_partition") as HTMLDivElement; - this.firmware_state = document.getElementById("firmware_state") as HTMLDivElement; - + this.firmware_state0 = document.getElementById("firmware_state0") as HTMLDivElement; + this.firmware_state1 = document.getElementById("firmware_state1") as HTMLDivElement; + + const file = document.getElementById("firmware_file") as HTMLInputElement; this.file1Upload = file this.file1Upload.onchange = () => { @@ -38,7 +41,8 @@ export class OTAView { setVersion(versionInfo: VersionInfo) { this.firmware_buildtime.innerText = versionInfo.build_time; this.firmware_githash.innerText = versionInfo.git_hash; - this.firmware_partition.innerText = versionInfo.partition; - this.firmware_state.innerText = versionInfo.ota_state; + this.firmware_partition.innerText = versionInfo.current; + this.firmware_state0.innerText = versionInfo.slot0_state; + this.firmware_state1.innerText = versionInfo.slot1_state; } } \ No newline at end of file