Compare commits
No commits in common. "c3dd03dcf3849d9067fb6b8dd1e1a4edfb02db7b" and "088bd6eb76e1d09424d93e5e38d0abe927e13a18" have entirely different histories.
c3dd03dcf3
...
088bd6eb76
|
@ -127,9 +127,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "cortex-m-semihosting"
|
||||
version = "0.3.7"
|
||||
version = "0.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6bffa6c1454368a6aa4811ae60964c38e6996d397ff8095a8b9211b1c1f749bc"
|
||||
checksum = "113ef0ecffee2b62b58f9380f4469099b30e9f9cbee2804771b4203ba1762cfa"
|
||||
dependencies = [
|
||||
"cortex-m",
|
||||
]
|
||||
|
@ -282,9 +282,9 @@ checksum = "4e20e6499bbbc412f280b04a42346b356c6fa0753d5fd22b7bd752ff34c778ee"
|
|||
|
||||
[[package]]
|
||||
name = "panic-semihosting"
|
||||
version = "0.5.6"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d55dedd501dfd02514646e0af4d7016ce36bc12ae177ef52056989966a1eec"
|
||||
checksum = "aed16eb761d0ee9161dd1319cb38c8007813b20f9720a5a682b283e7b8cdfe58"
|
||||
dependencies = [
|
||||
"cortex-m",
|
||||
"cortex-m-semihosting",
|
||||
|
@ -363,9 +363,9 @@ checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3"
|
|||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.118"
|
||||
version = "1.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06c64263859d87aa2eb554587e2d23183398d617427327cf2b3d0ed8c69e4800"
|
||||
checksum = "b88fa983de7720629c9387e9f517353ed404164b1e482c970a90c1a4aaf7dc1a"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
@ -382,25 +382,15 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.118"
|
||||
version = "1.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c84d3526699cd55261af4b941e4e725444df67aa4f9e6a3564f18030d12672df"
|
||||
checksum = "cbd1ae72adb44aab48f325a02444a5fc079349a8d804c1fc922aed3f7454c74e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sfkv"
|
||||
version = "0.1.0"
|
||||
source = "git+https://git.m-labs.hk/m-labs/sfkv.git#08262fc3e943dab8c6faa112dc313c9a245fce03"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"postcard",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smoltcp"
|
||||
version = "0.6.0"
|
||||
|
@ -446,7 +436,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "stm32f4xx-hal"
|
||||
version = "0.8.3"
|
||||
source = "git+https://github.com/astro/stm32f4xx-hal.git?branch=flash#45d804dce0cd72733767b4647ab9369a2f169a70"
|
||||
source = "git+https://github.com/stm32-rs/stm32f4xx-hal.git#e80925770d2fe72f0f01a7b46147f4e31d512689"
|
||||
dependencies = [
|
||||
"bare-metal 0.2.5",
|
||||
"cast",
|
||||
|
@ -463,9 +453,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.54"
|
||||
version = "1.0.48"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a2af957a63d6bd42255c359c93d9bfdb97076bd3b820897ce55ffbfbf107f44"
|
||||
checksum = "cc371affeffc477f42a221a1e4297aedcea33d47d19b61455588bd9d8f6b19ac"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
@ -501,9 +491,9 @@ dependencies = [
|
|||
"num-traits",
|
||||
"panic-abort",
|
||||
"panic-semihosting",
|
||||
"postcard",
|
||||
"serde",
|
||||
"serde-json-core",
|
||||
"sfkv",
|
||||
"smoltcp",
|
||||
"stm32-eth",
|
||||
"stm32f4xx-hal",
|
||||
|
|
|
@ -34,13 +34,13 @@ nb = "1"
|
|||
uom = { version = "0.30", default-features = false, features = ["autoconvert", "si", "f64", "use_serde"] }
|
||||
eeprom24x = "0.3"
|
||||
serde = { version = "1.0", default-features = false, features = ["derive"] }
|
||||
postcard = "0.5"
|
||||
heapless = "0.5"
|
||||
serde-json-core = "0.1"
|
||||
sfkv = { git = "https://git.m-labs.hk/m-labs/sfkv.git" }
|
||||
|
||||
[patch.crates-io]
|
||||
# TODO: pending https://github.com/stm32-rs/stm32f4xx-hal/pull/239
|
||||
stm32f4xx-hal = { git = "https://github.com/astro/stm32f4xx-hal.git", branch = "flash" }
|
||||
stm32f4xx-hal = { git = "https://github.com/stm32-rs/stm32f4xx-hal.git" }
|
||||
|
||||
|
||||
[features]
|
||||
semihosting = ["panic-semihosting", "cortex-m-log/semihosting"]
|
||||
|
|
62
README.md
62
README.md
|
@ -91,37 +91,37 @@ The scope of this setting is per TCP session.
|
|||
|
||||
### Commands
|
||||
|
||||
| Syntax | Function |
|
||||
| --- | --- |
|
||||
| `report` | Show current input |
|
||||
| `report mode` | Show current report mode |
|
||||
| `report mode <off/on>` | Set report mode |
|
||||
| `pwm` | Show current PWM settings |
|
||||
| `pwm <0/1> max_i_pos <amp>` | Set PWM duty cycle for **max_i_pos** to *ampere* |
|
||||
| `pwm <0/1> max_i_neg <amp>` | Set PWM duty cycle for **max_i_neg** to *ampere* |
|
||||
| `pwm <0/1> max_v <volts>` | Set PWM duty cycle for **max_v** to *volt* |
|
||||
| `pwm <0/1> i_set <amp>` | Disengage PID, set **i_set** DAC to *ampere* |
|
||||
| `pwm <0/1> pid` | Set PWM to be controlled by PID |
|
||||
| `center <0/1> <volts>` | Set the MAX1968 0A-centerpoint to *volts* |
|
||||
| `center <0/1> vref` | Set the MAX1968 0A-centerpoint to measure from VREF |
|
||||
| `pid` | Show PID configuration |
|
||||
| `pid <0/1> target <deg_celsius>` | Set the PID controller target temperature |
|
||||
| `pid <0/1> kp <value>` | Set proportional gain |
|
||||
| `pid <0/1> ki <value>` | Set integral gain (unit: 10 Hz) |
|
||||
| `pid <0/1> kd <value>` | Set differential gain (unit: 0.1 seconds) |
|
||||
| `pid <0/1> output_min <amp>` | Set mininum output |
|
||||
| `pid <0/1> output_max <amp>` | Set maximum output |
|
||||
| `pid <0/1> integral_min <value>` | Set integral lower bound |
|
||||
| `pid <0/1> integral_max <value>` | Set integral upper bound |
|
||||
| `s-h` | Show Steinhart-Hart equation parameters |
|
||||
| `s-h <0/1> <t0/b/r0> <value>` | Set Steinhart-Hart parameter for a channel |
|
||||
| `postfilter` | Show postfilter settings |
|
||||
| `postfilter <0/1> off` | Disable postfilter |
|
||||
| `postfilter <0/1> rate <rate>` | Set postfilter output data rate |
|
||||
| `load [0/1]` | Restore configuration for channel all/0/1 from flash |
|
||||
| `save [0/1]` | Save configuration for channel all/0/1 to flash |
|
||||
| `reset` | Reset the device |
|
||||
| `ipv4 <X.X.X.X/L> [Y.Y.Y.Y]` | Configure IPv4 address, netmask length, and optional default gateway |
|
||||
| Syntax | Function |
|
||||
| --- | --- |
|
||||
| `report` | Show current input |
|
||||
| `report mode` | Show current report mode |
|
||||
| `report mode <off/on>` | Set report mode |
|
||||
| `pwm` | Show current PWM settings |
|
||||
| `pwm <0/1> max_i_pos <amp>` | Set PWM duty cycle for **max_i_pos** to *ampere* |
|
||||
| `pwm <0/1> max_i_neg <amp>` | Set PWM duty cycle for **max_i_neg** to *ampere* |
|
||||
| `pwm <0/1> max_v <volts>` | Set PWM duty cycle for **max_v** to *volt* |
|
||||
| `pwm <0/1> i_set <amp>` | Disengage PID, set **i_set** DAC to *ampere* |
|
||||
| `pwm <0/1> pid` | Set PWM to be controlled by PID |
|
||||
| `center <0/1> <volts>` | Set the MAX1968 0A-centerpoint to *volts* |
|
||||
| `center <0/1> vref` | Set the MAX1968 0A-centerpoint to measure from VREF |
|
||||
| `pid` | Show PID configuration |
|
||||
| `pid <0/1> target <deg_celsius>` | Set the PID controller target temperature |
|
||||
| `pid <0/1> kp <value>` | Set proportional gain |
|
||||
| `pid <0/1> ki <value>` | Set integral gain (unit: 10 Hz) |
|
||||
| `pid <0/1> kd <value>` | Set differential gain (unit: 0.1 seconds) |
|
||||
| `pid <0/1> output_min <amp>` | Set mininum output |
|
||||
| `pid <0/1> output_max <amp>` | Set maximum output |
|
||||
| `pid <0/1> integral_min <value>` | Set integral lower bound |
|
||||
| `pid <0/1> integral_max <value>` | Set integral upper bound |
|
||||
| `s-h` | Show Steinhart-Hart equation parameters |
|
||||
| `s-h <0/1> <t0/b/r0> <value>` | Set Steinhart-Hart parameter for a channel |
|
||||
| `postfilter` | Show postfilter settings |
|
||||
| `postfilter <0/1> off` | Disable postfilter |
|
||||
| `postfilter <0/1> rate <rate>` | Set postfilter output data rate |
|
||||
| `load` | Restore configuration from EEPROM |
|
||||
| `save` | Save configuration to EEPROM |
|
||||
| `reset` | Reset the device |
|
||||
| `ipv4 <X.X.X.X>` | Configure IPv4 address |
|
||||
|
||||
|
||||
## USB
|
||||
|
|
|
@ -1 +1 @@
|
|||
"0nzkn048pis1dbvmxcrcvaxwxq0rssyb4b9a1v0rhwhnrwfb9sq7"
|
||||
"055x3b3kqi7bi17ya6iaiq9hlsiy8f3v6bn47s6dizc6y4xn9v2y"
|
||||
|
|
4
memory.x
4
memory.x
|
@ -1,8 +1,6 @@
|
|||
MEMORY
|
||||
{
|
||||
FLASH (rx) : ORIGIN = 0x8000000, LENGTH = 1024K
|
||||
/* reserved for config data */
|
||||
CONFIG (rx) : ORIGIN = 0x8100000, LENGTH = 16K
|
||||
FLASH (rx) : ORIGIN = 0x8000000, LENGTH = 2048K
|
||||
RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 112K
|
||||
RAM2 (xrw) : ORIGIN = 0x2001C000, LENGTH = 16K
|
||||
RAM3 (xrw) : ORIGIN = 0x20020000, LENGTH = 64K
|
||||
|
|
|
@ -10,7 +10,7 @@ pkgs.mkShell {
|
|||
name = "thermostat-env";
|
||||
buildInputs = with pkgs; [
|
||||
rust gcc
|
||||
openocd dfu-util
|
||||
openocd
|
||||
] ++ (with python3Packages; [
|
||||
numpy matplotlib
|
||||
]);
|
||||
|
|
|
@ -85,13 +85,6 @@ impl fmt::Display for Error {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Ipv4Config {
|
||||
pub address: [u8; 4],
|
||||
pub mask_len: u8,
|
||||
pub gateway: Option<[u8; 4]>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ShowCommand {
|
||||
Input,
|
||||
|
@ -139,14 +132,10 @@ pub enum CenterPoint {
|
|||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Command {
|
||||
Quit,
|
||||
Load {
|
||||
channel: Option<usize>,
|
||||
},
|
||||
Save {
|
||||
channel: Option<usize>,
|
||||
},
|
||||
Load,
|
||||
Save,
|
||||
Reset,
|
||||
Ipv4(Ipv4Config),
|
||||
Ipv4([u8; 4]),
|
||||
Show(ShowCommand),
|
||||
Reporting(bool),
|
||||
/// PWM parameter setting
|
||||
|
@ -448,39 +437,9 @@ fn postfilter(input: &[u8]) -> IResult<&[u8], Result<Command, Error>> {
|
|||
))(input)
|
||||
}
|
||||
|
||||
fn load(input: &[u8]) -> IResult<&[u8], Result<Command, Error>> {
|
||||
let (input, _) = tag("load")(input)?;
|
||||
let (input, channel) = alt((
|
||||
|input| {
|
||||
let (input, _) = whitespace(input)?;
|
||||
let (input, channel) = channel(input)?;
|
||||
let (input, _) = end(input)?;
|
||||
Ok((input, Some(channel)))
|
||||
},
|
||||
value(None, end)
|
||||
))(input)?;
|
||||
|
||||
let result = Ok(Command::Load { channel });
|
||||
Ok((input, result))
|
||||
}
|
||||
|
||||
fn save(input: &[u8]) -> IResult<&[u8], Result<Command, Error>> {
|
||||
let (input, _) = tag("save")(input)?;
|
||||
let (input, channel) = alt((
|
||||
|input| {
|
||||
let (input, _) = whitespace(input)?;
|
||||
let (input, channel) = channel(input)?;
|
||||
let (input, _) = end(input)?;
|
||||
Ok((input, Some(channel)))
|
||||
},
|
||||
value(None, end)
|
||||
))(input)?;
|
||||
|
||||
let result = Ok(Command::Save { channel });
|
||||
Ok((input, result))
|
||||
}
|
||||
|
||||
fn ipv4_addr(input: &[u8]) -> IResult<&[u8], Result<[u8; 4], Error>> {
|
||||
fn ipv4(input: &[u8]) -> IResult<&[u8], Result<Command, Error>> {
|
||||
let (input, _) = tag("ipv4")(input)?;
|
||||
let (input, _) = whitespace(input)?;
|
||||
let (input, a) = unsigned(input)?;
|
||||
let (input, _) = tag(".")(input)?;
|
||||
let (input, b) = unsigned(input)?;
|
||||
|
@ -488,39 +447,18 @@ fn ipv4_addr(input: &[u8]) -> IResult<&[u8], Result<[u8; 4], Error>> {
|
|||
let (input, c) = unsigned(input)?;
|
||||
let (input, _) = tag(".")(input)?;
|
||||
let (input, d) = unsigned(input)?;
|
||||
let address = move || Ok([a? as u8, b? as u8, c? as u8, d? as u8]);
|
||||
Ok((input, address()))
|
||||
}
|
||||
end(input)?;
|
||||
|
||||
fn ipv4(input: &[u8]) -> IResult<&[u8], Result<Command, Error>> {
|
||||
let (input, _) = tag("ipv4")(input)?;
|
||||
let (input, _) = whitespace(input)?;
|
||||
let (input, address) = ipv4_addr(input)?;
|
||||
let (input, _) = tag("/")(input)?;
|
||||
let (input, mask_len) = unsigned(input)?;
|
||||
let (input, gateway) = alt((
|
||||
|input| {
|
||||
let (input, _) = whitespace(input)?;
|
||||
let (input, gateway) = ipv4_addr(input)?;
|
||||
Ok((input, gateway.map(Some)))
|
||||
},
|
||||
value(Ok(None), end),
|
||||
))(input)?;
|
||||
|
||||
let result = move || {
|
||||
Ok(Command::Ipv4(Ipv4Config {
|
||||
address: address?,
|
||||
mask_len: mask_len? as u8,
|
||||
gateway: gateway?,
|
||||
}))
|
||||
};
|
||||
Ok((input, result()))
|
||||
let result = a.and_then(|a| b.and_then(|b| c.and_then(|c| d.map(|d|
|
||||
Command::Ipv4([a as u8, b as u8, c as u8, d as u8])
|
||||
))));
|
||||
Ok((input, result))
|
||||
}
|
||||
|
||||
fn command(input: &[u8]) -> IResult<&[u8], Result<Command, Error>> {
|
||||
alt((value(Ok(Command::Quit), tag("quit")),
|
||||
load,
|
||||
save,
|
||||
value(Ok(Command::Load), tag("load")),
|
||||
value(Ok(Command::Save), tag("save")),
|
||||
value(Ok(Command::Reset), tag("reset")),
|
||||
ipv4,
|
||||
map(report, Ok),
|
||||
|
@ -558,45 +496,19 @@ mod test {
|
|||
#[test]
|
||||
fn parse_load() {
|
||||
let command = Command::parse(b"load");
|
||||
assert_eq!(command, Ok(Command::Load { channel: None }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_load_channel() {
|
||||
let command = Command::parse(b"load 0");
|
||||
assert_eq!(command, Ok(Command::Load { channel: Some(0) }));
|
||||
assert_eq!(command, Ok(Command::Load));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_save() {
|
||||
let command = Command::parse(b"save");
|
||||
assert_eq!(command, Ok(Command::Save { channel: None }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_save_channel() {
|
||||
let command = Command::parse(b"save 0");
|
||||
assert_eq!(command, Ok(Command::Save { channel: Some(0) }));
|
||||
assert_eq!(command, Ok(Command::Save));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ipv4() {
|
||||
let command = Command::parse(b"ipv4 192.168.1.26/24");
|
||||
assert_eq!(command, Ok(Command::Ipv4(Ipv4Config {
|
||||
address: [192, 168, 1, 26],
|
||||
mask_len: 24,
|
||||
gateway: None,
|
||||
})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ipv4_and_gateway() {
|
||||
let command = Command::parse(b"ipv4 10.42.0.126/8 10.1.0.1");
|
||||
assert_eq!(command, Ok(Command::Ipv4(Ipv4Config {
|
||||
address: [10, 42, 0, 126],
|
||||
mask_len: 8,
|
||||
gateway: Some([10, 1, 0, 1]),
|
||||
})));
|
||||
let command = Command::parse(b"ipv4 192.168.1.26");
|
||||
assert_eq!(command, Ok(Command::Ipv4([192, 168, 1, 26])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
198
src/config.rs
198
src/config.rs
|
@ -1,24 +1,102 @@
|
|||
use postcard::{from_bytes, to_slice};
|
||||
use serde::{Serialize, Deserialize};
|
||||
use smoltcp::wire::Ipv4Address;
|
||||
use stm32f4xx_hal::i2c;
|
||||
use uom::si::{
|
||||
electric_potential::volt,
|
||||
electric_current::ampere,
|
||||
f64::{ElectricCurrent, ElectricPotential},
|
||||
electrical_resistance::ohm,
|
||||
f64::{ElectricCurrent, ElectricPotential, ElectricalResistance, ThermodynamicTemperature},
|
||||
thermodynamic_temperature::degree_celsius,
|
||||
};
|
||||
use crate::{
|
||||
ad7172::PostFilter,
|
||||
channels::Channels,
|
||||
channels::{CHANNELS, Channels},
|
||||
command_parser::CenterPoint,
|
||||
EEPROM_SIZE, EEPROM_PAGE_SIZE,
|
||||
pid,
|
||||
pins,
|
||||
steinhart_hart,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
Eeprom(eeprom24x::Error<i2c::Error>),
|
||||
Encode(postcard::Error),
|
||||
}
|
||||
|
||||
impl From<eeprom24x::Error<i2c::Error>> for Error {
|
||||
fn from(e: eeprom24x::Error<i2c::Error>) -> Self {
|
||||
Error::Eeprom(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<postcard::Error> for Error {
|
||||
fn from(e: postcard::Error) -> Self {
|
||||
Error::Encode(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// Just for encoding/decoding, actual state resides in ChannelState
|
||||
#[derive(Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
channels: [ChannelConfig; CHANNELS],
|
||||
pub ipv4_address: [u8; 4],
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn new(channels: &mut Channels, ipv4_address: Ipv4Address) -> Self {
|
||||
Config {
|
||||
channels: [
|
||||
ChannelConfig::new(channels, 0),
|
||||
ChannelConfig::new(channels, 1),
|
||||
],
|
||||
ipv4_address: ipv4_address.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// apply loaded config to system
|
||||
pub fn apply(&self, channels: &mut Channels) {
|
||||
for i in 0..CHANNELS {
|
||||
self.channels[i].apply(channels, i);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(eeprom: &mut pins::Eeprom) -> Result<Self, Error> {
|
||||
let mut buffer = [0; EEPROM_SIZE];
|
||||
eeprom.read_data(0, &mut buffer)?;
|
||||
log::info!("load: {:?}", buffer);
|
||||
let config = from_bytes(&mut buffer)?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn save(&self, eeprom: &mut pins::Eeprom) -> Result<(), Error> {
|
||||
let mut buffer = [0; EEPROM_SIZE];
|
||||
let config_buffer = to_slice(self, &mut buffer)?;
|
||||
log::info!("save: {:?}", config_buffer);
|
||||
|
||||
let mut addr = 0;
|
||||
for chunk in config_buffer.chunks(EEPROM_PAGE_SIZE) {
|
||||
'write_retry: loop {
|
||||
match eeprom.write_page(addr, chunk) {
|
||||
Ok(()) => break 'write_retry,
|
||||
Err(eeprom24x::Error::I2C(i2c::Error::NACK)) => {},
|
||||
Err(e) => Err(e)?,
|
||||
}
|
||||
}
|
||||
addr += chunk.len() as u32;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ChannelConfig {
|
||||
center: CenterPoint,
|
||||
pid: pid::Parameters,
|
||||
pid_target: f32,
|
||||
pid_engaged: bool,
|
||||
sh: steinhart_hart::Parameters,
|
||||
sh: SteinhartHartConfig,
|
||||
pwm: PwmLimits,
|
||||
/// uses variant `PostFilter::Invalid` instead of `None` to save space
|
||||
adc_postfilter: PostFilter,
|
||||
|
@ -37,8 +115,7 @@ impl ChannelConfig {
|
|||
center: state.center.clone(),
|
||||
pid: state.pid.parameters.clone(),
|
||||
pid_target: state.pid.target as f32,
|
||||
pid_engaged: state.pid_engaged,
|
||||
sh: state.sh.clone(),
|
||||
sh: (&state.sh).into(),
|
||||
pwm,
|
||||
adc_postfilter,
|
||||
}
|
||||
|
@ -49,8 +126,7 @@ impl ChannelConfig {
|
|||
state.center = self.center.clone();
|
||||
state.pid.parameters = self.pid.clone();
|
||||
state.pid.target = self.pid_target.into();
|
||||
state.pid_engaged = self.pid_engaged;
|
||||
state.sh = self.sh.clone();
|
||||
state.sh = (&self.sh).into();
|
||||
|
||||
self.pwm.apply(channels, channel);
|
||||
|
||||
|
@ -62,11 +138,38 @@ impl ChannelConfig {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
struct SteinhartHartConfig {
|
||||
t0: f32,
|
||||
r0: f32,
|
||||
b: f32,
|
||||
}
|
||||
|
||||
impl From<&steinhart_hart::Parameters> for SteinhartHartConfig {
|
||||
fn from(sh: &steinhart_hart::Parameters) -> Self {
|
||||
SteinhartHartConfig {
|
||||
t0: sh.t0.get::<degree_celsius>() as f32,
|
||||
r0: sh.r0.get::<ohm>() as f32,
|
||||
b: sh.b as f32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Into<steinhart_hart::Parameters> for &SteinhartHartConfig {
|
||||
fn into(self) -> steinhart_hart::Parameters {
|
||||
steinhart_hart::Parameters {
|
||||
t0: ThermodynamicTemperature::new::<degree_celsius>(self.t0.into()),
|
||||
r0: ElectricalResistance::new::<ohm>(self.r0.into()),
|
||||
b: self.b.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
struct PwmLimits {
|
||||
max_v: f64,
|
||||
max_i_pos: f64,
|
||||
max_i_neg: f64,
|
||||
max_v: f32,
|
||||
max_i_pos: f32,
|
||||
max_i_neg: f32,
|
||||
}
|
||||
|
||||
impl PwmLimits {
|
||||
|
@ -75,15 +178,76 @@ impl PwmLimits {
|
|||
let (max_i_pos, _) = channels.get_max_i_pos(channel);
|
||||
let (max_i_neg, _) = channels.get_max_i_neg(channel);
|
||||
PwmLimits {
|
||||
max_v: max_v.get::<volt>(),
|
||||
max_i_pos: max_i_pos.get::<ampere>(),
|
||||
max_i_neg: max_i_neg.get::<ampere>(),
|
||||
max_v: max_v.get::<volt>() as f32,
|
||||
max_i_pos: max_i_pos.get::<ampere>() as f32,
|
||||
max_i_neg: max_i_neg.get::<ampere>() as f32,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply(&self, channels: &mut Channels, channel: usize) {
|
||||
channels.set_max_v(channel, ElectricPotential::new::<volt>(self.max_v));
|
||||
channels.set_max_i_pos(channel, ElectricCurrent::new::<ampere>(self.max_i_pos));
|
||||
channels.set_max_i_neg(channel, ElectricCurrent::new::<ampere>(self.max_i_neg));
|
||||
channels.set_max_v(channel, ElectricPotential::new::<volt>(self.max_v.into()));
|
||||
channels.set_max_i_pos(channel, ElectricCurrent::new::<ampere>(self.max_i_pos.into()));
|
||||
channels.set_max_i_neg(channel, ElectricCurrent::new::<ampere>(self.max_i_neg.into()));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::DEFAULT_IPV4_ADDRESS;
|
||||
|
||||
#[test]
|
||||
fn test_fit_eeprom() {
|
||||
let channel_config = ChannelConfig {
|
||||
center: CenterPoint::Override(1.5),
|
||||
pid: pid::Parameters::default(),
|
||||
pid_target: 93.7,
|
||||
sh: (&steinhart_hart::Parameters::default()).into(),
|
||||
pwm: PwmLimits {
|
||||
max_v: 1.65,
|
||||
max_i_pos: 2.1,
|
||||
max_i_neg: 2.25,
|
||||
},
|
||||
adc_postfilter: PostFilter::F21SPS,
|
||||
};
|
||||
let config = Config {
|
||||
channels: [
|
||||
channel_config.clone(),
|
||||
channel_config.clone(),
|
||||
],
|
||||
ipv4_address: DEFAULT_IPV4_ADDRESS.0,
|
||||
};
|
||||
|
||||
let mut buffer = [0; EEPROM_SIZE];
|
||||
let buffer = to_slice(&config, &mut buffer).unwrap();
|
||||
assert!(buffer.len() <= EEPROM_SIZE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encode_decode() {
|
||||
let channel_config = ChannelConfig {
|
||||
center: CenterPoint::Override(1.5),
|
||||
pid: pid::Parameters::default(),
|
||||
pid_target: 93.7,
|
||||
sh: (&steinhart_hart::Parameters::default()).into(),
|
||||
pwm: PwmLimits {
|
||||
max_v: 1.65,
|
||||
max_i_pos: 2.1,
|
||||
max_i_neg: 2.25,
|
||||
},
|
||||
adc_postfilter: PostFilter::F21SPS,
|
||||
};
|
||||
let config = Config {
|
||||
channels: [
|
||||
channel_config.clone(),
|
||||
channel_config.clone(),
|
||||
],
|
||||
ipv4_address: DEFAULT_IPV4_ADDRESS.0,
|
||||
};
|
||||
|
||||
let mut buffer = [0; EEPROM_SIZE];
|
||||
to_slice(&config, &mut buffer).unwrap();
|
||||
let decoded: Config = from_bytes(&buffer).unwrap();
|
||||
assert_eq!(decoded, config);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,59 +0,0 @@
|
|||
use log::{info, error};
|
||||
use stm32f4xx_hal::{
|
||||
flash::{Error, FlashExt},
|
||||
stm32::FLASH,
|
||||
};
|
||||
use sfkv::{Store, StoreBackend};
|
||||
|
||||
/// 16 KiB
|
||||
pub const FLASH_SECTOR_SIZE: usize = 0x4000;
|
||||
pub const FLASH_SECTOR: u8 = 12;
|
||||
pub const FLASH_SECTOR_OFFSET: usize = 0x10_0000;
|
||||
static mut BACKUP_SPACE: [u8; FLASH_SECTOR_SIZE] = [0; FLASH_SECTOR_SIZE];
|
||||
|
||||
pub struct FlashBackend {
|
||||
flash: FLASH,
|
||||
}
|
||||
|
||||
impl StoreBackend for FlashBackend {
|
||||
type Data = [u8];
|
||||
|
||||
fn data(&self) -> &Self::Data {
|
||||
&self.flash.read()[FLASH_SECTOR_OFFSET..(FLASH_SECTOR_OFFSET + FLASH_SECTOR_SIZE)]
|
||||
}
|
||||
|
||||
type Error = Error;
|
||||
fn erase(&mut self) -> Result<(), Self::Error> {
|
||||
info!("erasing store flash");
|
||||
self.flash.unlocked().erase(FLASH_SECTOR)
|
||||
}
|
||||
|
||||
fn program(&mut self, offset: usize, payload: &[u8]) -> Result<(), Self::Error> {
|
||||
self.flash.unlocked()
|
||||
.program(FLASH_SECTOR_OFFSET + offset, payload.iter().cloned())
|
||||
}
|
||||
|
||||
|
||||
fn backup_space(&self) -> &'static mut [u8] {
|
||||
unsafe { &mut BACKUP_SPACE }
|
||||
}
|
||||
}
|
||||
|
||||
pub type FlashStore = Store<FlashBackend>;
|
||||
|
||||
pub fn store(flash: FLASH) -> FlashStore {
|
||||
let backend = FlashBackend { flash };
|
||||
let mut store = FlashStore::new(backend);
|
||||
|
||||
// just try to read the store
|
||||
match store.get_bytes_used() {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
error!("corrupt store, erasing. error: {:?}", e);
|
||||
let _ = store.erase()
|
||||
.map_err(|e| error!("flash erase failed: {:?}", e));
|
||||
}
|
||||
}
|
||||
|
||||
store
|
||||
}
|
|
@ -1,4 +1,3 @@
|
|||
#[cfg(not(feature = "semihosting"))]
|
||||
use crate::usb;
|
||||
|
||||
#[cfg(not(feature = "semihosting"))]
|
||||
|
|
101
src/main.rs
101
src/main.rs
|
@ -17,14 +17,14 @@ use cortex_m_rt::entry;
|
|||
use stm32f4xx_hal::{
|
||||
hal::watchdog::{WatchdogEnable, Watchdog},
|
||||
rcc::RccExt,
|
||||
stm32::{CorePeripherals, Peripherals, SCB},
|
||||
time::{U32Ext, MegaHertz},
|
||||
watchdog::IndependentWatchdog,
|
||||
time::{U32Ext, MegaHertz},
|
||||
stm32::{CorePeripherals, Peripherals, SCB},
|
||||
};
|
||||
use smoltcp::{
|
||||
time::Instant,
|
||||
socket::TcpSocket,
|
||||
wire::EthernetAddress,
|
||||
wire::{EthernetAddress, Ipv4Address},
|
||||
};
|
||||
use uom::{
|
||||
si::{
|
||||
|
@ -55,7 +55,7 @@ use server::Server;
|
|||
mod session;
|
||||
use session::{Session, SessionInput};
|
||||
mod command_parser;
|
||||
use command_parser::{Command, Ipv4Config, PwmPin, ShowCommand};
|
||||
use command_parser::{Command, ShowCommand, PwmPin};
|
||||
mod timer;
|
||||
mod pid;
|
||||
mod steinhart_hart;
|
||||
|
@ -64,8 +64,8 @@ use channels::{CHANNELS, Channels};
|
|||
mod channel;
|
||||
mod channel_state;
|
||||
mod config;
|
||||
use config::ChannelConfig;
|
||||
mod flash_store;
|
||||
use config::Config;
|
||||
|
||||
|
||||
const HSE: MegaHertz = MegaHertz(8);
|
||||
#[cfg(not(feature = "semihosting"))]
|
||||
|
@ -73,8 +73,10 @@ const WATCHDOG_INTERVAL: u32 = 1_000;
|
|||
#[cfg(feature = "semihosting")]
|
||||
const WATCHDOG_INTERVAL: u32 = 30_000;
|
||||
|
||||
const CHANNEL_CONFIG_KEY: [&str; 2] = ["ch0", "ch1"];
|
||||
pub const EEPROM_PAGE_SIZE: usize = 8;
|
||||
pub const EEPROM_SIZE: usize = 128;
|
||||
|
||||
pub const DEFAULT_IPV4_ADDRESS: Ipv4Address = Ipv4Address([192, 168, 1, 26]);
|
||||
const TCP_PORT: u16 = 23;
|
||||
|
||||
|
||||
|
@ -158,34 +160,15 @@ fn main() -> ! {
|
|||
|
||||
usb::State::setup(usb);
|
||||
|
||||
let mut store = flash_store::store(dp.FLASH);
|
||||
let mut store_value_buf = [0u8; 256];
|
||||
|
||||
let mut ipv4_address = DEFAULT_IPV4_ADDRESS;
|
||||
let mut channels = Channels::new(pins);
|
||||
for c in 0..CHANNELS {
|
||||
match store.read_value::<ChannelConfig>(CHANNEL_CONFIG_KEY[c]) {
|
||||
Ok(Some(config)) =>
|
||||
config.apply(&mut channels, c),
|
||||
Ok(None) =>
|
||||
error!("flash config not found for channel {}", c),
|
||||
Err(e) =>
|
||||
error!("unable to load config {} from flash: {:?}", c, e),
|
||||
}
|
||||
}
|
||||
|
||||
// default net config:
|
||||
let mut ipv4_config = Ipv4Config {
|
||||
address: [192, 168, 1, 26],
|
||||
mask_len: 24,
|
||||
gateway: None,
|
||||
};
|
||||
match store.read_value("ipv4") {
|
||||
Ok(Some(config)) =>
|
||||
ipv4_config = config,
|
||||
Ok(None) => {}
|
||||
Err(e) =>
|
||||
error!("cannot read ipv4 config: {:?}", e),
|
||||
}
|
||||
let _ = Config::load(&mut eeprom)
|
||||
.map(|config| {
|
||||
config.apply(&mut channels);
|
||||
ipv4_address = Ipv4Address::from_bytes(&config.ipv4_address);
|
||||
})
|
||||
.map_err(|e| warn!("error loading config: {:?}", e));
|
||||
info!("IPv4 address: {}", ipv4_address);
|
||||
|
||||
// EEPROM ships with a read-only EUI-48 identifier
|
||||
let mut eui48 = [0; 6];
|
||||
|
@ -193,12 +176,12 @@ fn main() -> ! {
|
|||
let hwaddr = EthernetAddress(eui48);
|
||||
info!("EEPROM MAC address: {}", hwaddr);
|
||||
|
||||
net::run(clocks, dp.ETHERNET_MAC, dp.ETHERNET_DMA, eth_pins, hwaddr, ipv4_config, |iface| {
|
||||
net::run(clocks, dp.ETHERNET_MAC, dp.ETHERNET_DMA, eth_pins, hwaddr, ipv4_address, |iface| {
|
||||
let mut new_ipv4_address = None;
|
||||
Server::<Session>::run(iface, |server| {
|
||||
leds.r1.off();
|
||||
|
||||
loop {
|
||||
let mut new_ipv4_config = None;
|
||||
let instant = Instant::from_millis(i64::from(timer::now()));
|
||||
let updated_channel = channels.poll_adc(instant);
|
||||
if let Some(channel) = updated_channel {
|
||||
|
@ -358,35 +341,26 @@ fn main() -> ! {
|
|||
error!("unable to choose postfilter for rate {:.3}", rate),
|
||||
}
|
||||
}
|
||||
Command::Load { channel } => {
|
||||
for c in 0..CHANNELS {
|
||||
if channel.is_none() || channel == Some(c) {
|
||||
match store.read_value::<ChannelConfig>(CHANNEL_CONFIG_KEY[c]) {
|
||||
Ok(Some(config)) =>
|
||||
config.apply(&mut channels, c),
|
||||
Ok(None) =>
|
||||
error!("flash config not found"),
|
||||
Err(e) =>
|
||||
error!("unable to load config from flash: {:?}", e),
|
||||
}
|
||||
Command::Load => {
|
||||
match Config::load(&mut eeprom) {
|
||||
Ok(config) => {
|
||||
config.apply(&mut channels);
|
||||
new_ipv4_address = Some(Ipv4Address::from_bytes(&config.ipv4_address));
|
||||
}
|
||||
Err(e) =>
|
||||
error!("unable to load eeprom config: {:?}", e),
|
||||
}
|
||||
}
|
||||
Command::Save { channel } => {
|
||||
for c in 0..CHANNELS {
|
||||
if channel.is_none() || channel == Some(c) {
|
||||
let config = ChannelConfig::new(&mut channels, c);
|
||||
let _ = store
|
||||
.write_value(CHANNEL_CONFIG_KEY[c], &config, &mut store_value_buf)
|
||||
.map_err(|e| error!("unable to save channel {} config to flash: {:?}", c, e));
|
||||
}
|
||||
Command::Save => {
|
||||
let config = Config::new(&mut channels, ipv4_address);
|
||||
match config.save(&mut eeprom) {
|
||||
Ok(()) => {},
|
||||
Err(e) =>
|
||||
error!("unable to save eeprom config: {:?}", e),
|
||||
}
|
||||
}
|
||||
Command::Ipv4(config) => {
|
||||
let _ = store
|
||||
.write_value("ipv4", &config, [0; 16])
|
||||
.map_err(|e| error!("unable to save ipv4 config to flash: {:?}", e));
|
||||
new_ipv4_config = Some(config);
|
||||
Command::Ipv4(address) => {
|
||||
new_ipv4_address = Some(Ipv4Address::from_bytes(&address));
|
||||
}
|
||||
Command::Reset => {
|
||||
for i in 0..CHANNELS {
|
||||
|
@ -412,8 +386,11 @@ fn main() -> ! {
|
|||
}
|
||||
});
|
||||
|
||||
// Apply new IPv4 address/gateway
|
||||
new_ipv4_config.map(|config| server.set_ipv4_config(config));
|
||||
// Apply new IPv4 address
|
||||
new_ipv4_address.map(|new_ipv4_address| {
|
||||
server.set_ipv4_address(ipv4_address);
|
||||
ipv4_address = new_ipv4_address;
|
||||
});
|
||||
|
||||
// Update watchdog
|
||||
wd.feed();
|
||||
|
|
26
src/net.rs
26
src/net.rs
|
@ -7,13 +7,9 @@ use stm32f4xx_hal::{
|
|||
rcc::Clocks,
|
||||
stm32::{interrupt, Peripherals, ETHERNET_MAC, ETHERNET_DMA},
|
||||
};
|
||||
use smoltcp::wire::{EthernetAddress, Ipv4Address, Ipv4Cidr};
|
||||
use smoltcp::iface::{
|
||||
EthernetInterfaceBuilder, EthernetInterface,
|
||||
NeighborCache, Routes,
|
||||
};
|
||||
use smoltcp::wire::{EthernetAddress, IpCidr, Ipv4Address};
|
||||
use smoltcp::iface::{NeighborCache, EthernetInterfaceBuilder, EthernetInterface};
|
||||
use stm32_eth::{Eth, RingEntry, PhyAddress, RxDescriptor, TxDescriptor};
|
||||
use crate::command_parser::Ipv4Config;
|
||||
use crate::pins::EthernetPins;
|
||||
|
||||
/// Not on the stack so that stack can be placed in CCMRAM (which the
|
||||
|
@ -33,7 +29,7 @@ pub fn run<F>(
|
|||
ethernet_mac: ETHERNET_MAC, ethernet_dma: ETHERNET_DMA,
|
||||
eth_pins: EthernetPins,
|
||||
ethernet_addr: EthernetAddress,
|
||||
ipv4_config: Ipv4Config,
|
||||
local_addr: Ipv4Address,
|
||||
f: F
|
||||
) where
|
||||
F: FnOnce(EthernetInterface<&mut stm32_eth::Eth<'static, 'static>>),
|
||||
|
@ -55,18 +51,15 @@ pub fn run<F>(
|
|||
eth_dev.enable_interrupt();
|
||||
|
||||
// IP stack
|
||||
let (ipv4_cidr, gateway) = split_ipv4_config(ipv4_config);
|
||||
let mut ip_addrs = [ipv4_cidr.into()];
|
||||
// Netmask 0 means we expect any IP address on the local segment.
|
||||
// No routing.
|
||||
let mut ip_addrs = [IpCidr::new(local_addr.into(), 0)];
|
||||
let mut neighbor_storage = [None; 16];
|
||||
let neighbor_cache = NeighborCache::new(&mut neighbor_storage[..]);
|
||||
let mut routes_storage = [None; 1];
|
||||
let mut routes = Routes::new(&mut routes_storage[..]);
|
||||
gateway.map(|gateway| routes.add_default_ipv4_route(gateway).unwrap());
|
||||
let iface = EthernetInterfaceBuilder::new(&mut eth_dev)
|
||||
.ethernet_addr(ethernet_addr)
|
||||
.ip_addrs(&mut ip_addrs[..])
|
||||
.neighbor_cache(neighbor_cache)
|
||||
.routes(routes)
|
||||
.finalize();
|
||||
|
||||
f(iface);
|
||||
|
@ -97,10 +90,3 @@ pub fn clear_pending(cs: &CriticalSection) {
|
|||
*NET_PENDING.borrow(cs)
|
||||
.borrow_mut() = false;
|
||||
}
|
||||
|
||||
/// utility for destructuring into smoltcp types
|
||||
pub fn split_ipv4_config(config: Ipv4Config) -> (Ipv4Cidr, Option<Ipv4Address>) {
|
||||
let cidr = Ipv4Cidr::new(Ipv4Address(config.address), config.mask_len);
|
||||
let gateway = config.gateway.map(Ipv4Address);
|
||||
(cidr, gateway)
|
||||
}
|
||||
|
|
|
@ -3,10 +3,9 @@ use smoltcp::{
|
|||
iface::EthernetInterface,
|
||||
socket::{SocketSet, SocketHandle, TcpSocket, TcpSocketBuffer, SocketRef},
|
||||
time::Instant,
|
||||
wire::{IpAddress, IpCidr, Ipv4Address, Ipv4Cidr},
|
||||
wire::{IpCidr, Ipv4Address, Ipv4Cidr},
|
||||
};
|
||||
use crate::command_parser::Ipv4Config;
|
||||
use crate::net::split_ipv4_config;
|
||||
|
||||
|
||||
pub struct SocketState<S> {
|
||||
handle: SocketHandle,
|
||||
|
@ -86,12 +85,12 @@ impl<'a, 'b, S: Default> Server<'a, 'b, S> {
|
|||
}
|
||||
}
|
||||
|
||||
fn set_ipv4_address(&mut self, ipv4_address: Ipv4Cidr) {
|
||||
pub fn set_ipv4_address(&mut self, ipv4_address: Ipv4Address) {
|
||||
self.net.update_ip_addrs(|addrs| {
|
||||
for addr in addrs.iter_mut() {
|
||||
match addr {
|
||||
IpCidr::Ipv4(_) => {
|
||||
*addr = IpCidr::Ipv4(ipv4_address);
|
||||
*addr = IpCidr::Ipv4(Ipv4Cidr::new(ipv4_address, 0));
|
||||
// done
|
||||
break
|
||||
}
|
||||
|
@ -102,23 +101,4 @@ impl<'a, 'b, S: Default> Server<'a, 'b, S> {
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn set_gateway(&mut self, gateway: Option<Ipv4Address>) {
|
||||
let routes = self.net.routes_mut();
|
||||
match gateway {
|
||||
None =>
|
||||
routes.update(|routes_storage| {
|
||||
routes_storage.remove(&IpCidr::new(IpAddress::v4(0, 0, 0, 0), 0));
|
||||
}),
|
||||
Some(gateway) => {
|
||||
routes.add_default_ipv4_route(gateway).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_ipv4_config(&mut self, config: Ipv4Config) {
|
||||
let (address, gateway) = split_ipv4_config(config);
|
||||
self.set_ipv4_address(address);
|
||||
self.set_gateway(gateway);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,10 +8,12 @@ use uom::si::{
|
|||
ratio::ratio,
|
||||
thermodynamic_temperature::{degree_celsius, kelvin},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::Serialize;
|
||||
|
||||
type JsonBuffer = heapless::Vec<u8, heapless::consts::U200>;
|
||||
|
||||
/// Steinhart-Hart equation parameters
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct Parameters {
|
||||
/// Base temperature
|
||||
pub t0: ThermodynamicTemperature,
|
||||
|
@ -27,6 +29,10 @@ impl Parameters {
|
|||
let inv_temp = 1.0 / self.t0.get::<kelvin>() + (r / self.r0).get::<ratio>().ln() / self.b;
|
||||
ThermodynamicTemperature::new::<kelvin>(1.0 / inv_temp)
|
||||
}
|
||||
|
||||
pub fn to_json(&self) -> Result<JsonBuffer, serde_json_core::ser::Error> {
|
||||
serde_json_core::to_vec(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Parameters {
|
||||
|
|
Loading…
Reference in New Issue