mirror of
https://github.com/LegitCamper/picocalc-os-rs.git
synced 2025-12-27 15:55:25 +00:00
can dynamically load applications
This commit is contained in:
15
Cargo.lock
generated
15
Cargo.lock
generated
@@ -1080,7 +1080,6 @@ version = "0.10.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0e961b33649994dcf69303af6b3a332c1228549e604d455d61ec5d2ab5e68d3a"
|
checksum = "0e961b33649994dcf69303af6b3a332c1228549e604d455d61ec5d2ab5e68d3a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
|
||||||
"plain",
|
"plain",
|
||||||
"scroll",
|
"scroll",
|
||||||
]
|
]
|
||||||
@@ -1830,20 +1829,6 @@ name = "scroll"
|
|||||||
version = "0.13.0"
|
version = "0.13.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c1257cd4248b4132760d6524d6dda4e053bc648c9070b960929bf50cfb1e7add"
|
checksum = "c1257cd4248b4132760d6524d6dda4e053bc648c9070b960929bf50cfb1e7add"
|
||||||
dependencies = [
|
|
||||||
"scroll_derive",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "scroll_derive"
|
|
||||||
version = "0.13.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "22fc4f90c27b57691bbaf11d8ecc7cfbfe98a4da6dbe60226115d322aa80c06e"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.104",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "semver"
|
name = "semver"
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use shared::keyboard::{KeyCode, KeyEvent, KeyState, Modifiers};
|
|||||||
pub type EntryFn = fn() -> Pin<Box<dyn Future<Output = ()>>>;
|
pub type EntryFn = fn() -> Pin<Box<dyn Future<Output = ()>>>;
|
||||||
|
|
||||||
#[unsafe(no_mangle)]
|
#[unsafe(no_mangle)]
|
||||||
#[unsafe(link_section = ".userapp")]
|
#[unsafe(link_section = ".user_reloc")]
|
||||||
pub static mut CALL_ABI_TABLE: [usize; CallAbiTable::COUNT] = [0; CallAbiTable::COUNT];
|
pub static mut CALL_ABI_TABLE: [usize; CallAbiTable::COUNT] = [0; CallAbiTable::COUNT];
|
||||||
|
|
||||||
#[repr(usize)]
|
#[repr(usize)]
|
||||||
|
|||||||
@@ -79,11 +79,7 @@ static_cell = "2.1.1"
|
|||||||
bitflags = "2.9.1"
|
bitflags = "2.9.1"
|
||||||
heapless = "0.8.0"
|
heapless = "0.8.0"
|
||||||
num_enum = { version = "0.7.4", default-features = false }
|
num_enum = { version = "0.7.4", default-features = false }
|
||||||
goblin = { version = "0.10.0", default-features = false, features = [
|
goblin = { version = "0.10.0", default-features = false, features = ["elf32"] }
|
||||||
"elf32",
|
|
||||||
"elf64",
|
|
||||||
"endian_fd",
|
|
||||||
] }
|
|
||||||
talc = "4.4.3"
|
talc = "4.4.3"
|
||||||
spin = "0.10.0"
|
spin = "0.10.0"
|
||||||
|
|
||||||
|
|||||||
@@ -1,51 +1,22 @@
|
|||||||
#![allow(static_mut_refs)]
|
#![allow(static_mut_refs)]
|
||||||
use crate::{abi, storage::SDCARD};
|
|
||||||
use abi_sys::{CallAbiTable, EntryFn};
|
use crate::{
|
||||||
use alloc::{boxed::Box, vec::Vec};
|
abi,
|
||||||
use core::{
|
storage::{File, SDCARD},
|
||||||
alloc::Layout,
|
|
||||||
ffi::c_void,
|
|
||||||
pin::Pin,
|
|
||||||
ptr::NonNull,
|
|
||||||
slice::from_raw_parts_mut,
|
|
||||||
task::{Context, Poll},
|
|
||||||
};
|
};
|
||||||
|
use abi_sys::{CallAbiTable, EntryFn};
|
||||||
|
use alloc::{vec, vec::Vec};
|
||||||
use embedded_sdmmc::ShortFileName;
|
use embedded_sdmmc::ShortFileName;
|
||||||
use goblin::elf::{Elf, header::ET_DYN, program_header::PT_LOAD, sym};
|
use goblin::{
|
||||||
|
elf::{
|
||||||
|
header::header32::Header,
|
||||||
|
program_header::program_header32::{PT_LOAD, ProgramHeader},
|
||||||
|
section_header::SHT_SYMTAB,
|
||||||
|
},
|
||||||
|
elf32::{section_header::SectionHeader, sym::Sym},
|
||||||
|
};
|
||||||
|
|
||||||
pub async fn read_binary(name: &ShortFileName) -> Option<Vec<u8>> {
|
const ELF32_HDR_SIZE: usize = 52;
|
||||||
let mut guard = SDCARD.get().lock().await;
|
|
||||||
let sd = guard.as_mut()?;
|
|
||||||
|
|
||||||
let mut buf = Vec::new();
|
|
||||||
|
|
||||||
defmt::info!("sd closure");
|
|
||||||
sd.access_root_dir(|root_dir| {
|
|
||||||
// Try to open the file directly by name
|
|
||||||
defmt::info!("trying to open file: {:?}", name);
|
|
||||||
if let Ok(file) = root_dir.open_file_in_dir(name, embedded_sdmmc::Mode::ReadOnly) {
|
|
||||||
defmt::info!("opened");
|
|
||||||
let mut temp = [0u8; 512];
|
|
||||||
|
|
||||||
defmt::info!("caching binary");
|
|
||||||
loop {
|
|
||||||
match file.read(&mut temp) {
|
|
||||||
Ok(n) if n > 0 => buf.extend_from_slice(&temp[..n]),
|
|
||||||
_ => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
defmt::info!("done");
|
|
||||||
let _ = file.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if buf.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(buf)
|
|
||||||
}
|
|
||||||
|
|
||||||
// userland ram region defined in memory.x
|
// userland ram region defined in memory.x
|
||||||
unsafe extern "C" {
|
unsafe extern "C" {
|
||||||
@@ -53,19 +24,127 @@ unsafe extern "C" {
|
|||||||
static __userapp_end__: u8;
|
static __userapp_end__: u8;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub unsafe fn load_binary(bytes: &[u8]) -> Result<EntryFn, &str> {
|
pub async unsafe fn load_binary(name: &ShortFileName) -> Result<EntryFn, &str> {
|
||||||
let elf = Elf::parse(&bytes).expect("Failed to parse ELF");
|
let mut sd_lock = SDCARD.get().lock().await;
|
||||||
|
let sd = sd_lock.as_mut().unwrap();
|
||||||
|
|
||||||
if elf.is_64 || elf.is_lib || !elf.little_endian {
|
let mut error = "";
|
||||||
return Err("Unsupported ELF type");
|
let mut entry = 0;
|
||||||
|
|
||||||
|
let mut header_buf = [0; ELF32_HDR_SIZE];
|
||||||
|
|
||||||
|
sd.read_file(name, |mut file| {
|
||||||
|
file.read(&mut header_buf).unwrap();
|
||||||
|
let elf_header = Header::from_bytes(&header_buf);
|
||||||
|
|
||||||
|
let mut program_headers_buf = vec![0_u8; elf_header.e_phentsize as usize];
|
||||||
|
for i in 1..=elf_header.e_phnum {
|
||||||
|
file.seek_from_start(elf_header.e_phoff + (elf_header.e_phentsize * i) as u32)
|
||||||
|
.unwrap();
|
||||||
|
file.read(&mut program_headers_buf).unwrap();
|
||||||
|
|
||||||
|
let ph = cast_phdr(&program_headers_buf);
|
||||||
|
|
||||||
|
if ph.p_type == PT_LOAD {
|
||||||
|
load_segment(&mut file, &ph).unwrap()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for ph in &elf.program_headers {
|
// MUST MATCH ABI EXACTLY
|
||||||
if ph.p_type == PT_LOAD {
|
let entries: &[(CallAbiTable, usize)] = &[
|
||||||
let vaddr = ph.p_vaddr as usize;
|
(CallAbiTable::Print, abi::print as usize),
|
||||||
let memsz = ph.p_memsz as usize;
|
(CallAbiTable::DrawIter, abi::draw_iter as usize),
|
||||||
|
(CallAbiTable::GetKey, abi::get_key as usize),
|
||||||
|
];
|
||||||
|
assert!(entries.len() == CallAbiTable::COUNT);
|
||||||
|
|
||||||
|
patch_abi(entries, &elf_header, &mut file).unwrap();
|
||||||
|
|
||||||
|
// TODO: dynamically search for abi table
|
||||||
|
|
||||||
|
entry = elf_header.e_entry as u32;
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
if entry != 0 {
|
||||||
|
Ok(unsafe { core::mem::transmute(entry) })
|
||||||
|
} else {
|
||||||
|
Err(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn patch_abi(
|
||||||
|
entries: &[(CallAbiTable, usize)],
|
||||||
|
elf_header: &Header,
|
||||||
|
file: &mut File,
|
||||||
|
) -> Result<(), ()> {
|
||||||
|
for i in 1..=elf_header.e_shnum {
|
||||||
|
let sh = read_section(file, &elf_header, i.into());
|
||||||
|
|
||||||
|
// find the symbol table
|
||||||
|
if sh.sh_type == SHT_SYMTAB {
|
||||||
|
let mut symtab_buf = vec![0u8; sh.sh_size as usize];
|
||||||
|
file.seek_from_start(sh.sh_offset).unwrap();
|
||||||
|
file.read(&mut symtab_buf).unwrap();
|
||||||
|
|
||||||
|
// Cast buffer into symbols
|
||||||
|
let sym_count = sh.sh_size as usize / sh.sh_entsize as usize;
|
||||||
|
for i in 0..sym_count {
|
||||||
|
let sym_bytes =
|
||||||
|
&symtab_buf[i * sh.sh_entsize as usize..(i + 1) * sh.sh_entsize as usize];
|
||||||
|
let sym = cast_sym(sym_bytes);
|
||||||
|
|
||||||
|
let str_sh = read_section(file, &elf_header, sh.sh_link);
|
||||||
|
|
||||||
|
let mut name = Vec::new();
|
||||||
|
file.seek_from_start(str_sh.sh_offset + sym.st_name)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let mut byte = [0u8; 1];
|
||||||
|
file.read(&mut byte).unwrap();
|
||||||
|
if byte[0] == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
name.push(byte[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let symbol_name = core::str::from_utf8(&name).unwrap();
|
||||||
|
if symbol_name == "CALL_ABI_TABLE" {
|
||||||
|
let table_base = sym.st_value as *mut usize;
|
||||||
|
|
||||||
|
for &(abi_idx, func_ptr) in entries {
|
||||||
|
unsafe {
|
||||||
|
table_base.add(abi_idx as usize).write(func_ptr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_section(file: &mut File, elf_header: &Header, section: u32) -> SectionHeader {
|
||||||
|
let mut section_header_buf = vec![0_u8; elf_header.e_shentsize as usize];
|
||||||
|
|
||||||
|
file.seek_from_start(elf_header.e_shoff + (elf_header.e_shentsize as u32 * section))
|
||||||
|
.unwrap();
|
||||||
|
file.read(&mut section_header_buf).unwrap();
|
||||||
|
|
||||||
|
cast_shdr(§ion_header_buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_segment(file: &mut File, ph: &ProgramHeader) -> Result<(), ()> {
|
||||||
|
let dst_start = ph.p_vaddr as *mut u8;
|
||||||
let filesz = ph.p_filesz as usize;
|
let filesz = ph.p_filesz as usize;
|
||||||
let offset = ph.p_offset as usize;
|
let memsz = ph.p_memsz as usize;
|
||||||
|
let vaddr = ph.p_vaddr as usize;
|
||||||
|
let mut remaining = filesz;
|
||||||
|
let mut dst_ptr = dst_start;
|
||||||
|
let mut file_offset = ph.p_offset;
|
||||||
|
|
||||||
let seg_start = vaddr;
|
let seg_start = vaddr;
|
||||||
let seg_end = vaddr + memsz;
|
let seg_end = vaddr + memsz;
|
||||||
@@ -80,40 +159,46 @@ pub unsafe fn load_binary(bytes: &[u8]) -> Result<EntryFn, &str> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Buffer for chunked reads (512 bytes is typical SD sector size)
|
||||||
|
let mut buf = [0u8; 512];
|
||||||
|
|
||||||
|
while remaining > 0 {
|
||||||
|
let to_read = core::cmp::min(remaining, buf.len());
|
||||||
|
// Read chunk from file
|
||||||
|
file.seek_from_start(file_offset).unwrap();
|
||||||
|
file.read(&mut buf[..to_read]).unwrap();
|
||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
let dst = seg_start as *mut u8;
|
// Copy chunk directly into destination memory
|
||||||
let src = bytes.as_ptr().add(offset);
|
core::ptr::copy_nonoverlapping(buf.as_ptr(), dst_ptr, to_read);
|
||||||
|
dst_ptr = dst_ptr.add(to_read);
|
||||||
|
}
|
||||||
|
|
||||||
// Copy initialized part
|
remaining -= to_read;
|
||||||
core::ptr::copy_nonoverlapping(src, dst, filesz);
|
file_offset += to_read as u32;
|
||||||
|
}
|
||||||
|
|
||||||
// Zero BSS region (memsz - filesz)
|
// Zero BSS (memsz - filesz)
|
||||||
if memsz > filesz {
|
if memsz > filesz {
|
||||||
core::ptr::write_bytes(dst.add(filesz), 0, memsz - filesz);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let call_abi_sym = elf
|
|
||||||
.syms
|
|
||||||
.iter()
|
|
||||||
.find(|s| elf.strtab.get_at(s.st_name).unwrap() == "CALL_ABI_TABLE")
|
|
||||||
.expect("syscall table not found");
|
|
||||||
|
|
||||||
let table_base = call_abi_sym.st_value as *mut usize;
|
|
||||||
|
|
||||||
let entries: &[(CallAbiTable, usize)] = &[
|
|
||||||
(CallAbiTable::Print, abi::print as usize),
|
|
||||||
(CallAbiTable::DrawIter, abi::draw_iter as usize),
|
|
||||||
(CallAbiTable::GetKey, abi::get_key as usize),
|
|
||||||
];
|
|
||||||
assert!(entries.len() == CallAbiTable::COUNT);
|
|
||||||
|
|
||||||
for &(abi_idx, func_ptr) in entries {
|
|
||||||
unsafe {
|
unsafe {
|
||||||
table_base.add(abi_idx as usize).write(func_ptr);
|
core::ptr::write_bytes(dst_ptr, 0, memsz - filesz);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(unsafe { core::mem::transmute(elf.entry as u32) })
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cast_phdr(buf: &[u8]) -> ProgramHeader {
|
||||||
|
assert!(buf.len() >= core::mem::size_of::<ProgramHeader>());
|
||||||
|
unsafe { core::ptr::read(buf.as_ptr() as *const ProgramHeader) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cast_shdr(buf: &[u8]) -> SectionHeader {
|
||||||
|
assert!(buf.len() >= core::mem::size_of::<SectionHeader>());
|
||||||
|
unsafe { core::ptr::read(buf.as_ptr() as *const SectionHeader) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cast_sym(buf: &[u8]) -> Sym {
|
||||||
|
assert!(buf.len() >= core::mem::size_of::<Sym>());
|
||||||
|
unsafe { core::ptr::read(buf.as_ptr() as *const Sym) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ async fn userland_task() {
|
|||||||
*state = TaskState::Kernel;
|
*state = TaskState::Kernel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defmt::info!("Executing Binary");
|
||||||
entry().await;
|
entry().await;
|
||||||
|
|
||||||
// enable kernel ui
|
// enable kernel ui
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use embedded_sdmmc::{
|
|||||||
Block, BlockCount, BlockDevice, BlockIdx, Directory, SdCard as SdmmcSdCard, TimeSource,
|
Block, BlockCount, BlockDevice, BlockIdx, Directory, SdCard as SdmmcSdCard, TimeSource,
|
||||||
Timestamp, Volume, VolumeIdx, VolumeManager, sdcard::Error,
|
Timestamp, Volume, VolumeIdx, VolumeManager, sdcard::Error,
|
||||||
};
|
};
|
||||||
use embedded_sdmmc::{LfnBuffer, ShortFileName};
|
use embedded_sdmmc::{File as SdFile, LfnBuffer, Mode, ShortFileName};
|
||||||
|
|
||||||
pub const MAX_DIRS: usize = 4;
|
pub const MAX_DIRS: usize = 4;
|
||||||
pub const MAX_FILES: usize = 5;
|
pub const MAX_FILES: usize = 5;
|
||||||
@@ -23,6 +23,7 @@ type SD = SdmmcSdCard<Device, Delay>;
|
|||||||
type VolMgr = VolumeManager<SD, DummyTimeSource, MAX_DIRS, MAX_FILES, MAX_VOLUMES>;
|
type VolMgr = VolumeManager<SD, DummyTimeSource, MAX_DIRS, MAX_FILES, MAX_VOLUMES>;
|
||||||
type Vol<'a> = Volume<'a, SD, DummyTimeSource, MAX_DIRS, MAX_FILES, MAX_VOLUMES>;
|
type Vol<'a> = Volume<'a, SD, DummyTimeSource, MAX_DIRS, MAX_FILES, MAX_VOLUMES>;
|
||||||
type Dir<'a> = Directory<'a, SD, DummyTimeSource, MAX_DIRS, MAX_FILES, MAX_VOLUMES>;
|
type Dir<'a> = Directory<'a, SD, DummyTimeSource, MAX_DIRS, MAX_FILES, MAX_VOLUMES>;
|
||||||
|
pub type File<'a> = SdFile<'a, SD, DummyTimeSource, MAX_DIRS, MAX_FILES, MAX_VOLUMES>;
|
||||||
|
|
||||||
pub static SDCARD: LazyLock<Mutex<NoopRawMutex, Option<SdCard>>> =
|
pub static SDCARD: LazyLock<Mutex<NoopRawMutex, Option<SdCard>>> =
|
||||||
LazyLock::new(|| Mutex::new(None));
|
LazyLock::new(|| Mutex::new(None));
|
||||||
@@ -113,6 +114,22 @@ impl SdCard {
|
|||||||
access(root_dir);
|
access(root_dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn read_file(
|
||||||
|
&mut self,
|
||||||
|
name: &ShortFileName,
|
||||||
|
mut access: impl FnMut(File),
|
||||||
|
) -> Result<(), ()> {
|
||||||
|
let mut res = Err(());
|
||||||
|
self.access_root_dir(|root_dir| {
|
||||||
|
if let Ok(file) = root_dir.open_file_in_dir(name, Mode::ReadOnly) {
|
||||||
|
res = Ok(());
|
||||||
|
access(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns a Vec of file names (long format) that match the given extension (e.g., "BIN")
|
/// Returns a Vec of file names (long format) that match the given extension (e.g., "BIN")
|
||||||
pub fn list_files_by_extension(&mut self, ext: &str) -> Result<Vec<FileName>, ()> {
|
pub fn list_files_by_extension(&mut self, ext: &str) -> Result<Vec<FileName>, ()> {
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
BINARY_CH, TASK_STATE, TaskState,
|
BINARY_CH, TASK_STATE, TaskState,
|
||||||
display::{FRAMEBUFFER, SCREEN_HEIGHT, SCREEN_WIDTH},
|
display::{FRAMEBUFFER, SCREEN_HEIGHT, SCREEN_WIDTH},
|
||||||
|
elf::load_binary,
|
||||||
format,
|
format,
|
||||||
peripherals::keyboard,
|
peripherals::keyboard,
|
||||||
storage::FileName,
|
storage::FileName,
|
||||||
@@ -66,14 +67,8 @@ pub async fn ui_handler() {
|
|||||||
[selections.current_selection as usize - 1]
|
[selections.current_selection as usize - 1]
|
||||||
.clone();
|
.clone();
|
||||||
|
|
||||||
defmt::info!(
|
let entry =
|
||||||
"loading selected binary: {:?}",
|
unsafe { load_binary(&selection.short_name).await.unwrap() };
|
||||||
&selection.long_name.as_str()
|
|
||||||
);
|
|
||||||
let bytes = crate::elf::read_binary(&selection.short_name)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let entry = unsafe { crate::elf::load_binary(&bytes).unwrap() };
|
|
||||||
BINARY_CH.send(entry).await;
|
BINARY_CH.send(entry).await;
|
||||||
}
|
}
|
||||||
_ => (),
|
_ => (),
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ pub static RESTART_USB: Signal<ThreadModeRawMutex, ()> = Signal::new();
|
|||||||
pub static ENABLE_SCSI: AtomicBool = AtomicBool::new(false);
|
pub static ENABLE_SCSI: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
pub async fn usb_handler(driver: Driver<'static, USB>) {
|
pub async fn usb_handler(driver: Driver<'static, USB>) {
|
||||||
let mut config = Config::new(0xc0de, 0xcafe);
|
let mut config = Config::new(0xc0de, 0xbabe);
|
||||||
config.manufacturer = Some("LegitCamper");
|
config.manufacturer = Some("LegitCamper");
|
||||||
config.product = Some("PicoCalc");
|
config.product = Some("PicoCalc");
|
||||||
config.serial_number = Some("01001100");
|
config.serial_number = Some("01001100");
|
||||||
|
|||||||
Reference in New Issue
Block a user