Repository: quartiq/stabilizer (language: rust)

Issue:
support changing applications settings via serial-settings and load/store in flash
* On boot the initial settings are loaded from flash, or `Default::default()` if invalid
* Then they are altered from retained, republished, and altered from non-retained pub messages on mqtt as currently done
* serial terminal allows accessing them (get and set) as well
* serial terminal allows comitting them to flash

Impact/feasibility to be determined.
This allows full standalone operation without network/broker.


Discussion / hints:
Theoretically this is quite easy to implement. The main hurdle that I foresee is that the `Settings` structure is owned by the Miniconf client and the serial interface respectively, so there's some thought that would need to go into how to maintain synchronization between these two structures. I.e. if we update settings via serial, we need to republish them to MQTT.

If we could somehow create a singleton of the settings that was referenced by the MQTT client and the serial interface, I believe this would become relatively trivial to implement.
I would only ever modify stored settings over serial and only load settings from flash on boot (like broker and IP currently). That solves repub.


Ah so the intent here would be to make the serial interface effectively only touch flash, as opposed to modifying the run-time state of the device? It seems like that would be a bit... unexpected? I don't see anything blocking us from allowing the serial interface to affect the run-time state as well outside of software complexity.
As for broker and IP. Those also need a reboot. If we call the flash settings to be the boot settings then all is well defined.
And as you say, repub races against potential host retained settings. Hard to clear all corner cases. Better leave simple settings precedence rules (default < flash < mqtt)
I agree w.r.t. the broker and IP settings being boot-time configuration, but the application run-time settings is what this issue is about, correct?

It seems like there would be limited usefulness to configuring run-time settings over serial if you had to save them and reboot the device constantly to have them take effect.

I would expect that:
1. Runtime settings would immediately apply to the device operational state when configured via Serial
2. There would be an option for me to "save" the operational state via the serial console when I'm happy with the configuration
   * Maybe this would be automatic, similar to how we auto-save the broker and IP currently
3. I could harmoniously use the MQTT interface to configure as well, but these values would not be auto-saved to flash
4. There would be some MQTT command to save the device configuration to flash so they're used on the next boot

Am I missing something here?
* There would quite some explaining, misunderstanding and potential frustration about why broker/ip need a reboot and e.g. streaming target doesn't.
* Imagine a retained setting in MQTT. Now you change that same setting over serial. Do you republish? Do you do so retained? If yes, how do you decide what's retained and what's not? Then you reboot the device. Loads the setting from flash, but the retained mqtt setting quickly overrides the flash setting. Confusion ensues (why did my setting via serial work just fine a while ago but again now doesn't).
Alright, the retained settings were indeed something I was concerned with as well. So the current thought is to expose a mechanism to configure + save runtime settings via the serial interface, but the serial interface never affects the current operating state of the device. I like this compromise.

Would we also want to expose a command over MQTT to save the current configuration?
I wouldn't do that now. The use case where the boot settings (flash settings) are dominant (because there's no network connection) shall require USB connection.

Fix patch:
diff --git a/Cargo.lock b/Cargo.lock
index 98903bf22..e256a9aa6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1354,8 +1354,8 @@ dependencies = [
 
 [[package]]
 name = "usbd-serial"
-version = "0.2.0"
-source = "git+https://github.com/rust-embedded-community/usbd-serial?rev=096742c1c480f6f63c1a936a3c23ede7993c624d#096742c1c480f6f63c1a936a3c23ede7993c624d"
+version = "0.2.1"
+source = "git+https://github.com/rust-embedded-community/usbd-serial?rev=ccff77578925942e40e0a2c7b6d397ad164fdedb#ccff77578925942e40e0a2c7b6d397ad164fdedb"
 dependencies = [
  "embedded-hal 0.2.7",
  "embedded-io",
diff --git a/Cargo.toml b/Cargo.toml
index 756481687..a69db2786 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -88,7 +88,7 @@ features = ["stm32h743v", "rt", "ethernet", "xspi", "usb_hs"]
 
 [patch.crates-io.usbd-serial]
 git = "https://github.com/rust-embedded-community/usbd-serial"
-rev = "096742c1c480f6f63c1a936a3c23ede7993c624d"
+rev = "ccff77578925942e40e0a2c7b6d397ad164fdedb"
 
 [features]
 nightly = []
diff --git a/book/src/setup.md b/book/src/setup.md
index ae6cc379f..b5a22edba 100644
--- a/book/src/setup.md
+++ b/book/src/setup.md
@@ -21,14 +21,41 @@ Power Stabilizer through **exactly one** of the following mechanisms.
 > **Note:** Applying power through more than one mechanism may lead to damage.
 > Ensure the two unused methods are not connected or explicitly disabled.
 
+## USB Configuration
+
+The USB port can be used to bootstrap Stabilizer and configure all internal settings. This is useful
+either when first configuring the MQTT connection or when operating Stabilizer in standalone mode
+(i.e. without an ethernet connection or an MQTT broker).
+
+Connect a USB cable and open up the serial port in a serial terminal of your choice. `pyserial`
+provides a simple, easy-to-use terminal emulator:
+```sh
+python -m serial <port>
+```
+
+Once you have opened the port, you can use the provided menu to update any of Stabilizers runtime
+settings. All settings configured via the USB interface are only applied when Stabilizer first boots
+up. Any modifications that occur after boot over the MQTT interface will occur after the initial
+settings configured via USB.
+
+> **Note:** Settings configured via USB do not take immediat effect. Instead, they will apply after
+> the device is rebooted.
+
+> **Note:** Settings configured via USB are only initial settings. Any modifications
+> that occur after startup (i.e. via MQTT) will take precedent over those configured via USB. MQTT
+> settings can only be persisted or retained via the MQTT broker.
+
+> **Note:** Saving settings via the USB port only saves the initial settings. It does not take into
+> account the current operational settings that may have been applied via MQTT.
+
 ## Network and DHCP
 
 Stabilizer supports 10Base-T or 100Base-T with Auto MDI-X.
-Stabilizer uses DHCP to obtain its network configuration information. Ensure there is a
-properly configured DHCP server running on the network segment that Stabilizer is
-connected to.
-Alternatively, a static IP can be enforced in the firmware build command by specifying
-the environmental variable `STATIC_IP` analogous to how a specific broker IP is set.
+
+Stabilizer uses DHCP to obtain its network configuration information. Ensure there is a properly
+configured DHCP server running on the network segment that Stabilizer is connected to. If a DHCP
+server is not available and a static IP is desired, Stabilizer can be configured with a static IP
+via the USB interface. A configured `ip` of "0.0.0.0" will use DHCP.
 
 > **Note:** If Stabilizer is connected directly to an Ubuntu system (for example using a USB-Ethernet dongle) 
 you can set the IPv4 settings of this Ethernet connection in the Ubuntu network settings to
@@ -179,16 +206,9 @@ missing timer deadlines and panicing.
 
 ## Set the MQTT broker
 
-The MQTT broker can be configured via the USB port on Stabilizer's front. Connect a USB cable and
-open up the serial port in a serial terminal of your choice. `pyserial` provides a simple,
-easy-to-use terminal emulator:
-```sh
-python -m serial <port>
-```
-
-Once you have opened the port, you can use the provided menu to update the MQTT broker address. The
-address can be an IP address or a domain name. Once the broker has been updated, power cycle
-stabilizer to have the new broker address take effect.
+The MQTT broker address is [configured via the USB port on Stabilizer's front](#usb-configuration).
+The address can be an IP address or a domain name. Once the broker address has been updated, power
+cycle stabilizer to have the new broker address take effect.
 
 ## Verify MQTT connection
 
diff --git a/serial-settings/src/lib.rs b/serial-settings/src/lib.rs
index cb188feaf..6dcf844b5 100644
--- a/serial-settings/src/lib.rs
+++ b/serial-settings/src/lib.rs
@@ -44,7 +44,7 @@
 //! qutoes.
 //!
 //! # Limitations
-//! Currently, there is a hardcoded limit of 32-bytes on the settings path. This is arbitrary and
+//! Currently, there is a hardcoded limit of 64-bytes on the settings path. This is arbitrary and
 //! can be changed if needed.
 #![no_std]
 
@@ -58,19 +58,21 @@ pub use interface::BestEffortInterface;
 
 /// Specifies the API required for objects that are used as settings with the serial terminal
 /// interface.
-pub trait Settings: for<'a> JsonCoreSlash<'a> + Clone {
+pub trait Settings<const Y: usize>:
+    for<'a> JsonCoreSlash<'a, Y> + Clone
+{
     /// Reset the settings to their default values.
     fn reset(&mut self) {}
 }
 
-pub trait Platform: Sized {
+pub trait Platform<const Y: usize>: Sized {
     /// This type specifies the interface to the user, for example, a USB CDC-ACM serial port.
     type Interface: embedded_io::Read
         + embedded_io::ReadReady
         + core::fmt::Write;
 
     /// Specifies the settings that are used on the device.
-    type Settings: Settings;
+    type Settings: Settings<Y>;
 
     /// `save()` Error type
     type Error: core::fmt::Debug;
@@ -91,12 +93,12 @@ pub trait Platform: Sized {
     fn settings_mut(&mut self) -> &mut Self::Settings;
 }
 
-struct Context<'a, P: Platform> {
+struct Context<'a, P: Platform<Y>, const Y: usize> {
     platform: P,
     buffer: &'a mut [u8],
 }
 
-impl<'a, P: Platform> Context<'a, P> {
+impl<'a, P: Platform<Y>, const Y: usize> Context<'a, P, Y> {
     fn handle_platform(
         _menu: &menu::Menu<Self>,
         item: &menu::Item<Self>,
@@ -116,7 +118,7 @@ impl<'a, P: Platform> Context<'a, P> {
         let mut defaults = context.platform.settings().clone();
         defaults.reset();
 
-        for path in P::Settings::iter_paths::<heapless::String<32>>("/") {
+        for path in P::Settings::iter_paths::<heapless::String<64>>("/") {
             match path {
                 Err(e) => writeln!(context, "Failed to get path: {e}"),
                 Ok(path) => {
@@ -297,16 +299,20 @@ impl<'a, P: Platform> Context<'a, P> {
     }
 }
 
-impl<'a, P: Platform> core::fmt::Write for Context<'a, P> {
+impl<'a, P: Platform<Y>, const Y: usize> core::fmt::Write
+    for Context<'a, P, Y>
+{
     fn write_str(&mut self, s: &str) -> core::fmt::Result {
         self.platform.interface_mut().write_str(s)
     }
 }
 
 /// The serial settings management object.
-pub struct Runner<'a, P: Platform>(menu::Runner<'a, Context<'a, P>>);
+pub struct Runner<'a, P: Platform<Y>, const Y: usize>(
+    menu::Runner<'a, Context<'a, P, Y>>,
+);
 
-impl<'a, P: Platform> Runner<'a, P> {
+impl<'a, P: Platform<Y>, const Y: usize> Runner<'a, P, Y> {
     /// Constructor
     ///
     /// # Args
diff --git a/src/bin/dual-iir.rs b/src/bin/dual-iir.rs
index 1cbb16b54..4b96f881f 100644
--- a/src/bin/dual-iir.rs
+++ b/src/bin/dual-iir.rs
@@ -16,7 +16,7 @@
 //! * Derivative kick avoidance
 //!
 //! ## Settings
-//! Refer to the [Settings] structure for documentation of run-time configurable settings for this
+//! Refer to the [DualIir] structure for documentation of run-time configurable settings for this
 //! application.
 //!
 //! ## Telemetry
@@ -30,6 +30,7 @@
 
 use core::mem::MaybeUninit;
 use core::sync::atomic::{fence, Ordering};
+use serde::{Deserialize, Serialize};
 
 use rtic_monotonics::Monotonic;
 
@@ -56,6 +57,7 @@ use stabilizer::{
         telemetry::{Telemetry, TelemetryBuffer},
         NetworkState, NetworkUsers,
     },
+    settings::NetSettings,
 };
 
 const SCALE: f32 = i16::MAX as _;
@@ -73,8 +75,39 @@ const SAMPLE_TICKS: u32 = 1 << SAMPLE_TICKS_LOG2;
 const SAMPLE_PERIOD: f32 =
     SAMPLE_TICKS as f32 * hardware::design_parameters::TIMER_PERIOD;
 
-#[derive(Clone, Copy, Debug, Tree)]
+#[derive(Clone, Debug, Tree)]
 pub struct Settings {
+    #[tree(depth(3))]
+    pub dual_iir: DualIir,
+
+    #[tree(depth(1))]
+    pub net: NetSettings,
+}
+
+impl stabilizer::settings::AppSettings for Settings {
+    fn new(net: NetSettings) -> Self {
+        Self {
+            net,
+            dual_iir: DualIir::default(),
+        }
+    }
+
+    fn net(&self) -> &NetSettings {
+        &self.net
+    }
+}
+
+impl serial_settings::Settings<4> for Settings {
+    fn reset(&mut self) {
+        *self = Self {
+            dual_iir: DualIir::default(),
+            net: NetSettings::new(self.net.mac),
+        }
+    }
+}
+
+#[derive(Clone, Copy, Debug, Tree, Serialize, Deserialize)]
+pub struct DualIir {
     /// Configure the Analog Front End (AFE) gain.
     ///
     /// # Path
@@ -148,7 +181,7 @@ pub struct Settings {
     signal_generator: [signal_generator::BasicConfig; 2],
 }
 
-impl Default for Settings {
+impl Default for DualIir {
     fn default() -> Self {
         let mut i = iir::Biquad::IDENTITY;
         i.set_min(-SCALE);
@@ -184,16 +217,16 @@ mod app {
     #[shared]
     struct Shared {
         usb: UsbDevice,
-        network: NetworkUsers<Settings, Telemetry, 3>,
+        network: NetworkUsers<DualIir, Telemetry, 3>,
 
-        settings: Settings,
+        settings: DualIir,
         telemetry: TelemetryBuffer,
         signal_generator: [SignalGenerator; 2],
     }
 
     #[local]
     struct Local {
-        usb_terminal: SerialTerminal,
+        usb_terminal: SerialTerminal<Settings, 4>,
         sampling_timer: SamplingTimer,
         digital_inputs: (DigitalInput0, DigitalInput1),
         afes: (AFE0, AFE1),
@@ -217,34 +250,31 @@ mod app {
             SAMPLE_TICKS,
         );
 
-        let settings = stabilizer.usb_serial.settings();
+        let settings: &Settings = stabilizer.usb_serial.settings();
         let mut network = NetworkUsers::new(
             stabilizer.net.stack,
             stabilizer.net.phy,
             clock,
             env!("CARGO_BIN_NAME"),
-            &settings.broker,
-            &settings.id,
+            &settings.net,
             stabilizer.metadata,
         );
 
         let generator = network.configure_streaming(StreamFormat::AdcDacData);
 
-        let settings = Settings::default();
-
         let shared = Shared {
             usb: stabilizer.usb,
             network,
-            settings,
+            settings: settings.dual_iir,
             telemetry: TelemetryBuffer::default(),
             signal_generator: [
                 SignalGenerator::new(
-                    settings.signal_generator[0]
+                    settings.dual_iir.signal_generator[0]
                         .try_into_config(SAMPLE_PERIOD, DacCode::FULL_SCALE)
                         .unwrap(),
                 ),
                 SignalGenerator::new(
-                    settings.signal_generator[1]
+                    settings.dual_iir.signal_generator[1]
                         .try_into_config(SAMPLE_PERIOD, DacCode::FULL_SCALE)
                         .unwrap(),
                 ),
diff --git a/src/bin/lockin.rs b/src/bin/lockin.rs
index 50eb4fba1..e6566f631 100644
--- a/src/bin/lockin.rs
+++ b/src/bin/lockin.rs
@@ -15,8 +15,8 @@
 //! * Input/output data streamng via UDP
 //!
 //! ## Settings
-//! Refer to the [Settings] structure for documentation of run-time configurable settings for this
-//! application.
+//! Refer to the [Lockin] structure for documentation of run-time configurable settings
+//! for this application.
 //!
 //! ## Telemetry
 //! Refer to [Telemetry] for information about telemetry reported by this application.
@@ -38,7 +38,7 @@ use rtic_monotonics::Monotonic;
 use fugit::ExtU32;
 use mutex_trait::prelude::*;
 
-use idsp::{Accu, Complex, ComplexExt, Filter, Lockin, Lowpass, Repeat, RPLL};
+use idsp::{Accu, Complex, ComplexExt, Filter, Lowpass, Repeat, RPLL};
 
 use stabilizer::{
     hardware::{
@@ -60,6 +60,7 @@ use stabilizer::{
         telemetry::{Telemetry, TelemetryBuffer},
         NetworkState, NetworkUsers,
     },
+    settings::NetSettings,
 };
 
 // The logarithm of the number of samples in each batch process. This corresponds with 2^3 samples
@@ -73,6 +74,37 @@ const BATCH_SIZE: usize = 1 << BATCH_SIZE_LOG2;
 const SAMPLE_TICKS_LOG2: u32 = 7;
 const SAMPLE_TICKS: u32 = 1 << SAMPLE_TICKS_LOG2;
 
+#[derive(Clone, Debug, Tree)]
+pub struct Settings {
+    #[tree(depth(2))]
+    pub lockin: Lockin,
+
+    #[tree(depth(1))]
+    pub net: NetSettings,
+}
+
+impl stabilizer::settings::AppSettings for Settings {
+    fn new(net: NetSettings) -> Self {
+        Self {
+            net,
+            lockin: Lockin::default(),
+        }
+    }
+
+    fn net(&self) -> &NetSettings {
+        &self.net
+    }
+}
+
+impl serial_settings::Settings<3> for Settings {
+    fn reset(&mut self) {
+        *self = Self {
+            lockin: Lockin::default(),
+            net: NetSettings::new(self.net.mac),
+        }
+    }
+}
+
 #[derive(Copy, Clone, Debug, Serialize, Deserialize)]
 enum Conf {
     /// Output the lockin magnitude.
@@ -100,7 +132,7 @@ enum LockinMode {
 }
 
 #[derive(Copy, Clone, Debug, Tree)]
-pub struct Settings {
+pub struct Lockin {
     /// Configure the Analog Front End (AFE) gain.
     ///
     /// # Path
@@ -192,7 +224,7 @@ pub struct Settings {
     stream_target: StreamTarget,
 }
 
-impl Default for Settings {
+impl Default for Lockin {
     fn default() -> Self {
         Self {
             afe: [Gain::G1; 2],
@@ -221,14 +253,14 @@ mod app {
     #[shared]
     struct Shared {
         usb: UsbDevice,
-        network: NetworkUsers<Settings, Telemetry, 2>,
-        settings: Settings,
+        network: NetworkUsers<Lockin, Telemetry, 2>,
+        settings: Lockin,
         telemetry: TelemetryBuffer,
     }
 
     #[local]
     struct Local {
-        usb_terminal: SerialTerminal,
+        usb_terminal: SerialTerminal<Settings, 3>,
         sampling_timer: SamplingTimer,
         digital_inputs: (DigitalInput0, DigitalInput1),
         timestamper: InputStamper,
@@ -236,7 +268,7 @@ mod app {
         adcs: (Adc0Input, Adc1Input),
         dacs: (Dac0Output, Dac1Output),
         pll: RPLL,
-        lockin: Lockin<Repeat<2, Lowpass<2>>>,
+        lockin: idsp::Lockin<Repeat<2, Lowpass<2>>>,
         signal_generator: signal_generator::SignalGenerator,
         generator: FrameGenerator,
         cpu_temp_sensor: stabilizer::hardware::cpu_temp_sensor::CpuTempSensor,
@@ -255,14 +287,14 @@ mod app {
             SAMPLE_TICKS,
         );
 
-        let settings = stabilizer.usb_serial.settings();
+        let settings: &Settings = stabilizer.usb_serial.settings();
+
         let mut network = NetworkUsers::new(
             stabilizer.net.stack,
             stabilizer.net.phy,
             clock,
             env!("CARGO_BIN_NAME"),
-            &settings.broker,
-            &settings.id,
+            &settings.net,
             stabilizer.metadata,
         );
 
@@ -272,7 +304,7 @@ mod app {
             network,
             usb: stabilizer.usb,
             telemetry: TelemetryBuffer::default(),
-            settings: Settings::default(),
+            settings: settings.lockin,
         };
 
         let signal_config = signal_generator::Config {
@@ -294,7 +326,7 @@ mod app {
             timestamper: stabilizer.timestamper,
 
             pll: RPLL::new(SAMPLE_TICKS_LOG2 + BATCH_SIZE_LOG2),
-            lockin: Lockin::default(),
+            lockin: idsp::Lockin::default(),
             signal_generator: signal_generator::SignalGenerator::new(
                 signal_config,
             ),
diff --git a/src/hardware/mod.rs b/src/hardware/mod.rs
index 21746676c..76c477d8b 100644
--- a/src/hardware/mod.rs
+++ b/src/hardware/mod.rs
@@ -84,8 +84,18 @@ pub type I2c1 = hal::i2c::I2c<hal::stm32::I2C1>;
 pub type I2c1Proxy =
     shared_bus::I2cProxy<'static, shared_bus::AtomicCheckMutex<I2c1>>;
 
-pub type SerialTerminal =
-    serial_settings::Runner<'static, crate::settings::SerialSettingsPlatform>;
+pub type SerialPort = usbd_serial::SerialPort<
+    'static,
+    crate::hardware::UsbBus,
+    &'static mut setup::SerialBufferStore,
+    &'static mut setup::SerialBufferStore,
+>;
+
+pub type SerialTerminal<C, const Y: usize> = serial_settings::Runner<
+    'static,
+    crate::settings::SerialSettingsPlatform<C, Y>,
+    Y,
+>;
 
 pub enum HardwareVersion {
     Rev1_0,
diff --git a/src/hardware/setup.rs b/src/hardware/setup.rs
index 006c52d94..1971301bd 100644
--- a/src/hardware/setup.rs
+++ b/src/hardware/setup.rs
@@ -13,6 +13,8 @@ use stm32h7xx_hal::{
 
 use smoltcp_nal::smoltcp;
 
+use crate::settings::{AppSettings, NetSettings};
+
 use super::{
     adc, afe, cpu_temp_sensor::CpuTempSensor, dac, delay, design_parameters,
     eeprom, input_stamper::InputStamper, metadata::ApplicationMetadata,
@@ -27,6 +29,26 @@ const NUM_TCP_SOCKETS: usize = 4;
 const NUM_UDP_SOCKETS: usize = 1;
 const NUM_SOCKETS: usize = NUM_UDP_SOCKETS + NUM_TCP_SOCKETS;
 
+pub struct SerialBufferStore([u8; 1024]);
+
+impl Default for SerialBufferStore {
+    fn default() -> Self {
+        Self([0u8; 1024])
+    }
+}
+
+impl core::borrow::Borrow<[u8]> for &mut SerialBufferStore {
+    fn borrow(&self) -> &[u8] {
+        &self.0
+    }
+}
+
+impl core::borrow::BorrowMut<[u8]> for &mut SerialBufferStore {
+    fn borrow_mut(&mut self) -> &mut [u8] {
+        &mut self.0
+    }
+}
+
 pub struct NetStorage {
     pub ip_addrs: [smoltcp::wire::IpCidr; 1],
 
@@ -107,7 +129,10 @@ pub struct EemGpioDevices {
 }
 
 /// The available hardware interfaces on Stabilizer.
-pub struct StabilizerDevices {
+pub struct StabilizerDevices<
+    C: serial_settings::Settings<Y> + 'static,
+    const Y: usize,
+> {
     pub temperature_sensor: CpuTempSensor,
     pub afes: (AFE0, AFE1),
     pub adcs: (adc::Adc0Input, adc::Adc1Input),
@@ -118,7 +143,7 @@ pub struct StabilizerDevices {
     pub net: NetworkDevices,
     pub digital_inputs: (DigitalInput0, DigitalInput1),
     pub eem_gpio: EemGpioDevices,
-    pub usb_serial: SerialTerminal,
+    pub usb_serial: SerialTerminal<C, Y>,
     pub usb: UsbDevice,
     pub metadata: &'static ApplicationMetadata,
 }
@@ -200,13 +225,18 @@ fn load_itcm() {
 /// stabilizer hardware interfaces in a disabled state. `pounder` is an `Option` containing
 /// `Some(devices)` if pounder is detected, where `devices` is a `PounderDevices` structure
 /// containing all of the pounder hardware interfaces in a disabled state.
-pub fn setup(
+pub fn setup<C, const Y: usize>(
     mut core: stm32h7xx_hal::stm32::CorePeripherals,
     device: stm32h7xx_hal::stm32::Peripherals,
     clock: SystemTimer,
     batch_size: usize,
     sample_ticks: u32,
-) -> (StabilizerDevices, Option<PounderDevices>) {
+) -> (StabilizerDevices<C, Y>, Option<PounderDevices>)
+where
+    C: serial_settings::Settings<Y>
+        + for<'d> miniconf::JsonCoreSlash<'d, Y>
+        + AppSettings,
+{
     // Set up RTT logging
     {
         // Enable debug during WFE/WFI-induced sleep
@@ -624,6 +654,17 @@ pub fn setup(
     ));
     log::info!("EUI48: {}", mac_addr);
 
+    let (flash, settings) = {
+        let mut flash = {
+            let (_, flash_bank2) = device.FLASH.split();
+            super::flash::Flash(flash_bank2.unwrap())
+        };
+
+        let mut settings = C::new(NetSettings::new(mac_addr.clone()));
+        crate::settings::load_from_flash(&mut settings, &mut flash);
+        (flash, settings)
+    };
+
     let network_devices = {
         let ethernet_pins = {
             // Reset the PHY before configuring pins.
@@ -668,10 +709,14 @@ pub fn setup(
         unsafe { ethernet::enable_interrupt() };
 
         // Configure IP address according to DHCP socket availability
-        let ip_addrs: smoltcp::wire::IpAddress = option_env!("STATIC_IP")
-            .unwrap_or("0.0.0.0")
-            .parse()
-            .unwrap();
+        let ip_addrs: smoltcp::wire::IpAddress = match settings.net().ip.parse()
+        {
+            Ok(addr) => addr,
+            Err(e) => {
+                log::warn!("Invalid IP address in settings: {e:?}. Defaulting to 0.0.0.0 (DHCP)");
+                "0.0.0.0".parse().unwrap()
+            }
+        };
 
         let random_seed = {
             let mut rng =
@@ -1075,7 +1120,16 @@ pub fn setup(
             &mut endpoint_memory[..],
         ));
 
-        let serial = usbd_serial::SerialPort::new(usb_bus.as_ref().unwrap());
+        let rx_buffer = cortex_m::singleton!(: SerialBufferStore = SerialBufferStore::default()).unwrap();
+        let tx_buffer = cortex_m::singleton!(: SerialBufferStore = SerialBufferStore::default()).unwrap();
+
+        let serial = {
+            usbd_serial::SerialPort::new_with_store(
+                usb_bus.as_ref().unwrap(),
+                rx_buffer,
+                tx_buffer,
+            )
+        };
         let usb_device = usb_device::device::UsbDeviceBuilder::new(
             usb_bus.as_ref().unwrap(),
             usb_device::device::UsbVidPid(0x1209, 0x392F),
@@ -1091,25 +1145,18 @@ pub fn setup(
         (usb_device, serial)
     };
 
-    let usb_serial = {
-        let (_, flash_bank2) = device.FLASH.split();
-
+    let usb_terminal = {
         let input_buffer =
             cortex_m::singleton!(: [u8; 256] = [0u8; 256]).unwrap();
         let serialize_buffer =
             cortex_m::singleton!(: [u8; 512] = [0u8; 512]).unwrap();
 
-        let mut storage = super::flash::Flash(flash_bank2.unwrap());
-        let mut settings =
-            crate::settings::Settings::new(network_devices.mac_address);
-        settings.reload(&mut storage);
-
         serial_settings::Runner::new(
             crate::settings::SerialSettingsPlatform {
                 interface: serial_settings::BestEffortInterface::new(
                     usb_serial,
                 ),
-                storage,
+                storage: flash,
                 settings,
                 metadata,
             },
@@ -1126,6 +1173,7 @@ pub fn setup(
         temperature_sensor: CpuTempSensor::new(
             adc3.create_channel(hal::adc::Temperature::new()),
         ),
+        usb_serial: usb_terminal,
         timestamper: input_stamper,
         net: network_devices,
         adc_dac_timer: sampling_timer,
@@ -1133,7 +1181,6 @@ pub fn setup(
         digital_inputs,
         eem_gpio,
         usb: usb_device,
-        usb_serial,
         metadata,
     };
 
diff --git a/src/hardware/signal_generator.rs b/src/hardware/signal_generator.rs
index 3a8b4d259..542520456 100644
--- a/src/hardware/signal_generator.rs
+++ b/src/hardware/signal_generator.rs
@@ -20,7 +20,7 @@ pub enum Signal {
 /// Where `<signal>` may be any of [Signal] variants, `frequency` specifies the signal frequency
 /// in Hertz, `symmetry` specifies the normalized signal symmetry which ranges from 0 - 1.0, and
 /// `amplitude` specifies the signal amplitude in Volts.
-#[derive(Copy, Clone, Debug, Tree)]
+#[derive(Copy, Clone, Debug, Tree, Serialize, Deserialize)]
 pub struct BasicConfig {
     /// The signal type that should be generated. See [Signal] variants.
     pub signal: Signal,
diff --git a/src/net/mod.rs b/src/net/mod.rs
index 4248049dd..1c6d359c3 100644
--- a/src/net/mod.rs
+++ b/src/net/mod.rs
@@ -17,6 +17,7 @@ use crate::hardware::{
     metadata::ApplicationMetadata, EthernetPhy, NetworkManager, NetworkStack,
     SystemTimer,
 };
+use crate::settings::NetSettings;
 use data_stream::{DataStream, FrameGenerator};
 use network_processor::NetworkProcessor;
 use telemetry::TelemetryClient;
@@ -87,8 +88,7 @@ where
     /// * `phy` - The ethernet PHY connecting the network.
     /// * `clock` - A `SystemTimer` implementing `Clock`.
     /// * `app` - The name of the application.
-    /// * `broker` - The domain name of the MQTT broker to use.
-    /// * `id` - The MQTT client ID base to use.
+    /// * `net_settings` - The network-specific settings to use for the application.
     /// * `metadata` - The application metadata
     ///
     /// # Returns
@@ -98,8 +98,7 @@ where
         phy: EthernetPhy,
         clock: SystemTimer,
         app: &str,
-        broker: &str,
-        id: &str,
+        net_settings: &NetSettings,
         metadata: &'static ApplicationMetadata,
     ) -> Self {
         let stack_manager =
@@ -109,14 +108,14 @@ where
         let processor =
             NetworkProcessor::new(stack_manager.acquire_stack(), phy);
 
-        let prefix = get_device_prefix(app, id);
+        let prefix = get_device_prefix(app, &net_settings.id);
 
         let store =
             cortex_m::singleton!(: MqttStorage = MqttStorage::default())
                 .unwrap();
 
         let named_broker = miniconf::minimq::broker::NamedBroker::new(
-            broker,
+            &net_settings.broker,
             stack_manager.acquire_stack(),
         )
         .unwrap();
@@ -129,13 +128,13 @@ where
                 named_broker,
                 &mut store.settings,
             )
-            .client_id(&get_client_id(id, "settings"))
+            .client_id(&get_client_id(&net_settings.id, "settings"))
             .unwrap(),
         )
         .unwrap();
 
         let named_broker = minimq::broker::NamedBroker::new(
-            broker,
+            &net_settings.broker,
             stack_manager.acquire_stack(),
         )
         .unwrap();
@@ -146,7 +145,7 @@ where
                 // The telemetry client doesn't receive any messages except MQTT control packets.
                 // As such, we don't need much of the buffer for RX.
                 .rx_buffer(minimq::config::BufferConfig::Maximum(100))
-                .client_id(&get_client_id(id, "tlm"))
+                .client_id(&get_client_id(&net_settings.id, "tlm"))
                 .unwrap(),
         );
 
diff --git a/src/settings.rs b/src/settings.rs
index 2c8dcf920..82623d49b 100644
--- a/src/settings.rs
+++ b/src/settings.rs
@@ -6,7 +6,7 @@
 //! 2. Dynamic Run-time Settings
 //!
 //! Static device configuration settings are loaded and used only at device power-up. These include
-//! things like the MQTT broker address and the MQTT identified. Conversely, the dynamic run-time
+//! things like the MQTT broker address and the MQTT identifier. Conversely, the dynamic run-time
 //! settings can be changed and take effect immediately during device operation.
 //!
 //! This settings management interface is currently targeted at the static device configuration
@@ -24,70 +24,85 @@
 //!    storage sharing.
 use crate::hardware::{flash::Flash, metadata::ApplicationMetadata, platform};
 use core::fmt::Write;
-use miniconf::{TreeDeserialize, TreeKey, TreeSerialize};
+use miniconf::Tree;
 use postcard::ser_flavors::Flavor;
 use stm32h7xx_hal::flash::LockedFlashBank;
 
-#[derive(Clone, miniconf::Tree)]
-pub struct Settings {
+/// Settings that are used for configuring the network interface to Stabilizer.
+#[derive(Clone, Debug, Tree)]
+pub struct NetSettings {
+    /// The broker domain name (or IP address) to use for MQTT connections.
     pub broker: heapless::String<255>,
+
+    /// The MQTT ID to use upon connection with a broker.
     pub id: heapless::String<23>,
+
+    /// An optional static IP address to use. An unspecified IP address (or malformed address) will
+    /// use DHCP.
+    pub ip: heapless::String<15>,
     #[tree(skip)]
+    /// The MAC address of Stabilizer, which is used to reinitialize the ID to default settings.
     pub mac: smoltcp_nal::smoltcp::wire::EthernetAddress,
 }
 
-impl serial_settings::Settings for Settings {
-    fn reset(&mut self) {
-        *self = Self::new(self.mac)
-    }
-}
-
-impl Settings {
+impl NetSettings {
     pub fn new(mac: smoltcp_nal::smoltcp::wire::EthernetAddress) -> Self {
         let mut id = heapless::String::new();
         write!(&mut id, "{mac}").unwrap();
 
         Self {
             broker: "mqtt".into(),
+            ip: "0.0.0.0".into(),
             id,
             mac,
         }
     }
+}
 
-    pub fn reload(&mut self, storage: &mut Flash) {
-        // Loop over flash and read settings
-        let mut buffer = [0u8; 512];
-        for path in Settings::iter_paths::<heapless::String<64>>("/") {
-            let path = path.unwrap();
-
-            // Try to fetch the setting from flash.
-            let item = match sequential_storage::map::fetch_item::<
-                SettingsItem,
-                _,
-            >(
-                storage,
-                storage.range(),
-                &mut buffer,
-                path.clone(),
-            ) {
-                Err(e) => {
-                    log::warn!("Failed to load flash setting `{path}`: {e:?}");
-                    continue;
-                }
-                Ok(Some(item)) => item,
-                _ => continue,
-            };
+pub trait AppSettings {
+    /// Construct the settings given known network settings.
+    fn new(net: NetSettings) -> Self;
+
+    /// Get the network settings from the application settings.
+    fn net(&self) -> &NetSettings;
+}
 
-            log::info!("Found `{path}` in flash settings");
+pub fn load_from_flash<
+    T: for<'d> miniconf::JsonCoreSlash<'d, Y>,
+    const Y: usize,
+>(
+    structure: &mut T,
+    storage: &mut Flash,
+) {
+    // Loop over flash and read settings
+    let mut buffer = [0u8; 512];
+    for path in T::iter_paths::<heapless::String<64>>("/") {
+        let path = path.unwrap();
 
-            let mut deserializer = postcard::Deserializer::from_flavor(
-                postcard::de_flavors::Slice::new(&item.data),
-            );
-            if let Err(e) = self
-                .deserialize_by_key(path.split('/').skip(1), &mut deserializer)
-            {
-                log::warn!("Failed to load {path} from flash settings: {e:?}");
+        // Try to fetch the setting from flash.
+        let item = match sequential_storage::map::fetch_item::<SettingsItem, _>(
+            storage,
+            storage.range(),
+            &mut buffer,
+            path.clone(),
+        ) {
+            Err(e) => {
+                log::warn!("Failed to fetch `{path}` from flash: {e:?}");
+                continue;
             }
+            Ok(Some(item)) => item,
+            _ => continue,
+        };
+
+        log::info!("Loading initial `{path}` from flash");
+
+        let mut deserializer = postcard::Deserializer::from_flavor(
+            postcard::de_flavors::Slice::new(&item.data),
+        );
+        if let Err(e) = structure
+            .deserialize_by_key(path.split('/').skip(1), &mut deserializer)
+        {
+            log::warn!("Failed to deserialize `{path}` from flash: {e:?}");
         }
     }
 }
@@ -128,13 +143,13 @@ impl<F> From<postcard::Error> for Error<F> {
     }
 }
 
-pub struct SerialSettingsPlatform {
+pub struct SerialSettingsPlatform<C, const Y: usize> {
     /// The interface to read/write data to/from serially (via text) to the user.
-    pub interface: serial_settings::BestEffortInterface<
-        usbd_serial::SerialPort<'static, crate::hardware::UsbBus>,
-    >,
+    pub interface:
+        serial_settings::BestEffortInterface<crate::hardware::SerialPort>,
+
     /// The Settings structure.
-    pub settings: Settings,
+    pub settings: C,
     /// The storage mechanism used to persist settings to between boots.
     pub storage: Flash,
 
@@ -142,17 +157,20 @@ pub struct SerialSettingsPlatform {
     pub metadata: &'static ApplicationMetadata,
 }
 
-impl serial_settings::Platform for SerialSettingsPlatform {
-    type Interface = serial_settings::BestEffortInterface<
-        usbd_serial::SerialPort<'static, crate::hardware::UsbBus>,
-    >;
-    type Settings = Settings;
+impl<C, const Y: usize> serial_settings::Platform<Y>
+    for SerialSettingsPlatform<C, Y>
+where
+    C: serial_settings::Settings<Y>,
+{
+    type Interface =
+        serial_settings::BestEffortInterface<crate::hardware::SerialPort>;
+    type Settings = C;
     type Error = Error<
         <LockedFlashBank as embedded_storage::nor_flash::ErrorType>::Error,
     >;
 
     fn save(&mut self, buf: &mut [u8]) -> Result<(), Self::Error> {
-        for path in Settings::iter_paths::<heapless::String<64>>("/") {
+        for path in Self::Settings::iter_paths::<heapless::String<64>>("/") {
             let mut item = SettingsItem {
                 path: path.unwrap(),
                 ..Default::default()
@@ -168,7 +186,7 @@ impl serial_settings::Platform for SerialSettingsPlatform {
                 .settings
                 .serialize_by_key(item.path.split('/').skip(1), &mut serializer)
             {
-                log::warn!("Failed to save {} to flash: {e:?}", item.path);
+                log::warn!("Failed to save `{}` to flash: {e:?}", item.path);
                 continue;
             }
 
@@ -189,7 +207,7 @@ impl serial_settings::Platform for SerialSettingsPlatform {
             .map(|old| old.data != item.data)
             .unwrap_or(true)
             {
-                log::info!("Storing setting `{}` in flash", item.path);
+                log::info!("Storing `{}` to flash", item.path);
                 sequential_storage::map::store_item(
                     &mut self.storage,
                     range,


Test patch: