use crate::{
aux::check_address_overflow,
drives::{DiskImageType, Drive, DriveList},
petscii::Petscii,
};
use anyhow::{anyhow, bail, ensure, Ok, Result};
use core::fmt::Display;
use log::{debug, warn};
use reqwest::{
blocking::{Body, Client, Response},
header::{HeaderMap, HeaderValue},
StatusCode,
};
use std::{collections::HashMap, path::Path, thread::sleep, time::Duration};
use url::Host;
pub mod aux;
pub mod drives;
pub mod petscii;
pub mod vicstream;
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
pub struct DeviceInfo {
pub product: String,
pub firmware_version: String,
pub fpga_version: String,
pub core_version: Option<String>,
pub hostname: String,
pub unique_id: Option<String>,
}
impl Display for DeviceInfo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} (firmware {}, fpga {}, core {}, id {})",
self.product,
self.firmware_version,
self.fpga_version,
self.core_version.as_deref().unwrap_or("N/A"),
self.unique_id.as_deref().unwrap_or("N/A")
)
}
}
#[derive(Debug)]
pub struct Rest {
client: Client,
url_prefix: String,
headers: HeaderMap,
}
impl Rest {
pub fn new(host: &Host, password: Option<String>) -> Result<Self> {
let mut headers = HeaderMap::default();
if let Some(pw) = password {
headers.insert("X-password", HeaderValue::from_str(pw.as_str())?);
}
Ok(Self {
client: Client::new(),
url_prefix: format!("http://{host}/v1"),
headers,
})
}
fn check_response(response: &Response) -> Result<()> {
match response.status() {
StatusCode::FORBIDDEN => bail!("access denied: check password or device settings"),
StatusCode::NOT_IMPLEMENTED => bail!("command unavailable on this Ultimate device"),
_ => {}
}
ensure!(
response.status().is_success(),
"request failed with status: {}",
response.status()
);
Ok(())
}
fn put(&self, path: &str) -> Result<Response> {
let url = format!("{}/{}", self.url_prefix, path);
let response = self.client.put(url).headers(self.headers.clone()).send()?;
Self::check_response(&response)?;
Ok(response)
}
fn get(&self, path: &str) -> Result<Response> {
let url = format!("{}/{}", self.url_prefix, path);
let response = self.client.get(url).headers(self.headers.clone()).send()?;
Self::check_response(&response)?;
Ok(response)
}
fn post<T: Into<Body>>(&self, path: &str, body: T) -> Result<Response> {
let url = format!("{}/{}", self.url_prefix, path);
let response = self
.client
.post(url)
.body(body)
.headers(self.headers.clone())
.send()?;
Self::check_response(&response)?;
Ok(response)
}
pub fn info(&self) -> Result<DeviceInfo> {
let body = self.get("info")?.text()?;
Ok(serde_json::from_str(&body)?)
}
pub fn version(&self) -> Result<String> {
let response = self.get("version")?;
let body = response.text()?;
Ok(body)
}
pub fn drives(&self) -> Result<String> {
let response = self.get("drives")?;
let body = response.text()?;
Ok(body)
}
pub fn load_prg(&self, prg_data: &[u8]) -> Result<()> {
debug!("Load PRG file of {} bytes", prg_data.len());
self.post("runners:load_prg", prg_data.to_vec())?;
Ok(())
}
pub fn run_prg(&self, data: &[u8]) -> Result<()> {
debug!("Run PRG file of {} bytes", data.len());
self.post("runners:run_prg", data.to_vec())?;
Ok(())
}
pub fn run_crt(&self, data: &[u8]) -> Result<()> {
debug!("Run CRT file of {} bytes", data.len());
self.post("runners:run_crt", data.to_vec())?;
Ok(())
}
pub fn menu(&self) -> Result<()> {
debug!("Emulating menu button press");
self.put("machine:menu_button")?;
Ok(())
}
pub fn reset(&self) -> Result<()> {
debug!("Reset machine");
self.put("machine:reset")?;
Ok(())
}
pub fn reboot(&self) -> Result<()> {
debug!("Reboot machine");
self.put("machine:reboot")?;
Ok(())
}
pub fn pause(&self) -> Result<()> {
debug!("Pause machine");
self.put("machine:pause")?;
Ok(())
}
pub fn resume(&self) -> Result<()> {
debug!("Resume machine");
self.put("machine:resume")?;
Ok(())
}
pub fn poweroff(&self) -> Result<()> {
debug!("Poweroff machine");
self.put("machine:poweroff")?;
Ok(())
}
pub fn write_mem(&self, address: u16, data: &[u8]) -> Result<()> {
check_address_overflow(address, data.len() as u16)?;
if matches!(address, 0 | 1) {
warn!("DMA cannot access internal CPU registers at address 0 and 1");
}
let path = format!("machine:writemem?address={address:x}");
self.post(&path, data.to_vec())?;
debug!("Wrote {} byte(s) to {:#06x}", data.len(), address);
Ok(())
}
pub fn type_text(&self, s: &str) -> Result<()> {
debug!("Emulating keyboard typing: {s}");
const KEYBOARD_LSTX: u16 = 0xc5; const KEYBOARD_NDX: u16 = 0xc6; const KEYBOARD_BUFFER: u16 = 0x277;
ensure!(
self.basic_ready()?,
"cannot emulate typing as BASIC prompt is not ready"
);
let petscii: Vec<u8> = s
.chars()
.map(|c| Petscii::from_str_lossy(&c.to_string())[0])
.collect();
for chunk in petscii.chunks(10) {
self.write_mem(KEYBOARD_LSTX, &[0, 0])?; self.write_mem(KEYBOARD_BUFFER, chunk)?; self.write_mem(KEYBOARD_NDX, &[chunk.len() as u8])?; sleep(Duration::from_millis(20)); }
Ok(())
}
pub fn read_le_word(&self, address: u16) -> Result<u16> {
let bytes: [u8; 2] = self
.read_mem(address, 2)?
.try_into()
.map_err(|_| anyhow!("failed to read from {address:#06x}"))?;
Ok(u16::from_le_bytes(bytes))
}
#[allow(unused)]
fn basic_ready(&self) -> Result<bool> {
return Ok(true);
todo!("implement correct basic_ready check");
const BASIN_ADDR: u16 = 0xa7ae; const VECTOR_ADDR: u16 = 0x0302; let word = self.read_le_word(VECTOR_ADDR)?;
debug!("Word at {VECTOR_ADDR:#06x} is {word:#06x}");
ensure!(
word != 0,
"BASIC prompt is not ready, vector at {VECTOR_ADDR:#06x} is zero"
);
Ok(self.read_le_word(VECTOR_ADDR)? == BASIN_ADDR)
}
pub fn read_mem(&self, address: u16, length: u16) -> Result<Vec<u8>> {
check_address_overflow(address, length)?;
if matches!(address, 0 | 1) {
warn!("Warning: DMA cannot access internal CPU registers at address 0 and 1");
}
let path = format!("machine:readmem?address={address:x}&length={length}");
let bytes = self.get(path.as_str())?.bytes()?.to_vec();
debug!("Read {length} byte(s) from {address:#06x}");
Ok(bytes)
}
pub fn sid_play(&self, siddata: &[u8], songnr: Option<u8>) -> Result<()> {
let path = match songnr {
Some(songnr) => format!("runners:sidplay?songnr={songnr}"),
None => "runners:sidplay".to_string(),
};
self.post(&path, siddata.to_vec())?;
Ok(())
}
pub fn mod_play(&self, moddata: &[u8]) -> Result<()> {
self.post("runners:modplay", moddata.to_vec())?;
Ok(())
}
pub fn load_data(&self, data: &[u8], address: Option<u16>) -> Result<(u16, usize)> {
match address {
Some(address) => {
self.write_mem(address, data)?;
Ok((address, data.len()))
}
None => {
let load_address = aux::extract_load_address(data)?;
self.write_mem(load_address, &data[2..])?; Ok((load_address, data.len() - 2))
}
}
}
pub fn drive_list(&self) -> Result<HashMap<String, Drive>> {
let response = self.get("drives")?;
let nested: DriveList = response.json()?;
let drives = nested
.drives
.iter()
.flat_map(|m| m.iter().map(|(name, drive)| (name.clone(), drive.clone())))
.collect();
Ok(drives)
}
pub fn mount_disk_image<P: AsRef<Path>>(
&self,
path: P,
drive: String,
mount_mode: drives::MountMode,
run: bool,
) -> Result<()> {
let disktype = DiskImageType::from_file_name(&path)?;
let url = format!("{}/drives/{drive}:mount", self.url_prefix);
let form = reqwest::blocking::multipart::Form::new()
.file("file", path)
.map_err(|e| anyhow!("disk image error: {e}"))?
.text("mode", mount_mode.to_string())
.text("type", disktype.to_string());
let response = self
.client
.post(url)
.multipart(form)
.headers(self.headers.clone())
.send()?;
Self::check_response(&response)?;
if response.status().is_client_error() {
bail!(
"disk mount error: {} - {}",
response.status(),
response.text().unwrap()
);
}
if run {
self.reset()?;
sleep(Duration::from_secs(2));
self.type_text("load\"*\",8,1\nrun\n")?;
}
Ok(())
}
}