Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Tools refactoring #238

Merged
merged 23 commits into from
Apr 1, 2024
Merged
Changes from 1 commit
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
4bee5ef
Rewrite pdtool (#231)
boozook Mar 30, 2024
5519ee1
fix ctrl const: remove `#[const_trait]`
boozook Mar 31, 2024
e8e434c
Merge pull request #232 from boozook/api/fix-ctrl-const
boozook Mar 31, 2024
e536a57
fix readme, add cargo-playdate to CI dev builds
boozook Mar 31, 2024
128b9fa
Report success if SDK has been found
eirnym Mar 31, 2024
4ad69a5
Merge pull request #234 from eirnym/sdk-search-success
boozook Apr 1, 2024
d8429a1
various small improvements
boozook Apr 1, 2024
4a7a1e6
hot-fix for "missed cargo products", `PackageId` format breaking chan…
boozook Apr 1, 2024
16ebf30
add third format support (with missed crate name) for
boozook Apr 1, 2024
333abf4
bump `cargo-playdate` version
boozook Apr 1, 2024
bd6f4bf
Merge branch 'main' into dev/refactoring/tools
boozook Apr 1, 2024
040782d
CI: install linux deps for tools
boozook Apr 1, 2024
c2a0b09
change `cargo-playdate` version
boozook Apr 1, 2024
8295555
CI: tests on gh- aarm64 mac
boozook Apr 1, 2024
597dc0c
Use XDG paths to search for Playdate SDK
eirnym Apr 1, 2024
1180661
CI: more tests on gh- aarm64
boozook Apr 1, 2024
aaf30a9
Merge pull request #240 from eirnym/xdg-config-search
boozook Apr 1, 2024
ac9075f
bump build-utils and bindgen,
boozook Apr 1, 2024
e2af8af
CI: enable build-utils tests on windows
boozook Apr 1, 2024
5ca6f05
Remove windows deps that previously was, but unnecessary now
boozook Apr 1, 2024
85ebd13
Check Windows registry for SDK path
eirnym Apr 1, 2024
a9aad8c
CI: disable tests on codemagic-CI because aarch64 existing on GH.
boozook Apr 1, 2024
c5977be
Merge pull request #241 from eirnym/registry-windows
boozook Apr 1, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Rewrite pdtool (#231)
* pdtool refactoring

* upd deps

* `cargo-playdate`, fixes

* add tracing

* CI: dev build
boozook authored Mar 30, 2024
commit 4bee5ef889b21aa74eab43e619a6722a519edcd8
121 changes: 121 additions & 0 deletions .github/workflows/dev-build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
name: Dev
on:
workflow_dispatch:
inputs:
source:
description: "Source ref used to build bindings. Uses `github.ref`` by default."
required: false
sha:
description: "Source SHA used to build bindings. Uses `github.sha`` by default."
required: false
push:
branches: [dev/**, refactor/**]

env:
CARGO_NET_RETRY: 10
RUSTUP_MAX_RETRIES: 10
CARGO_TERM_COLOR: always
CARGO_TERM_PROGRESS_WHEN: never
CARGO_INCREMENTAL: 1
# logging:
RUST_LOG: trace
CARGO_PLAYDATE_LOG: trace

jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os:
- macos-latest
- ubuntu-latest
- windows-latest
defaults:
run:
shell: bash

steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.source || github.ref || github.event.ref }}

- name: Cache
uses: actions/[email protected]
with:
path: |
target/
~/.cargo
key: ${{ runner.os }}-dev-build-${{ hashFiles('Cargo.lock') }}

- name: Config
run: |
mkdir -p .cargo
cp -rf .github/config.toml .cargo/config.toml
- name: Cache LLVM
id: cache-llvm
if: runner.os == 'Windows'
uses: actions/[email protected]
with:
path: ${{ runner.temp }}/llvm
key: llvm-14.0

# See:
# https://github.com/rust-lang/rust-bindgen/issues/1797
# https://rust-lang.github.io/rust-bindgen/requirements.html#windows
- name: Install LLVM
if: runner.os == 'Windows'
uses: KyleMayes/[email protected]
with:
version: "14.0"
directory: ${{ runner.temp }}/llvm
cached: ${{ steps.cache-llvm.outputs.cache-hit }}
env: true


- name: Install linux deps
if: runner.os == 'Linux'
run: |
sudo apt install pkg-config -y
sudo apt install libudev-dev -y
- name: pdtool with
continue-on-error: true
run: cargo build -p=playdate-tool --bin=pdtool

- name: Upload
id: upload
uses: actions/upload-artifact@v4
with:
name: pdtool-${{ runner.os }}-${{ runner.arch }}${{ ((runner.os == 'Windows') && '.exe') || ' ' }}
path: target/debug/pdtool${{ ((runner.os == 'Windows') && '.exe') || ' ' }}
if-no-files-found: warn
retention-days: 3
overwrite: true
- name: Artifact
run: |
echo 'ID: ${{ steps.upload.outputs.artifact-id }}'
echo 'URL: ${{ steps.upload.outputs.artifact-url }}'
- name: pdtool with tracing
continue-on-error: true
run: cargo build -p=playdate-tool --bin=pdtool --features=tracing

- name: Upload
uses: actions/upload-artifact@v4
with:
name: pdtool+tracing-${{ runner.os }}-${{ runner.arch }}${{ ((runner.os == 'Windows') && '.exe') || ' ' }}
path: target/debug/pdtool${{ ((runner.os == 'Windows') && '.exe') || ' ' }}
if-no-files-found: warn
retention-days: 3
overwrite: true
- name: Artifact
run: |
echo 'ID: ${{ steps.upload.outputs.artifact-id }}'
echo 'URL: ${{ steps.upload.outputs.artifact-url }}'
outputs:
artifact-id: ${{ steps.upload.outputs.artifact-id }}
artifact-url: ${{ steps.upload.outputs.artifact-url }}
1,823 changes: 1,477 additions & 346 deletions Cargo.lock

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -29,6 +29,8 @@ sys = { version = "0.3", path = "api/sys", package = "playdate-sys", default-fea
tool = { version = "0.1", path = "support/tool", package = "playdate-tool" }
build = { version = "0.2", path = "support/build", package = "playdate-build", default-features = false }
utils = { version = "0.1", path = "support/utils", package = "playdate-build-utils", default-features = false }
device = { version = "0.1", path = "support/device", package = "playdate-device" }
simulator = { version = "0.1", path = "support/sim-ctrl", package = "playdate-simulator-utils", default-features = false }
bindgen = { version = "0.1", path = "support/bindgen", package = "playdate-bindgen", default-features = false }
bindgen-cfg = { version = "0.1", path = "support/bindgen-cfg", package = "playdate-bindgen-cfg", default-features = false }

@@ -40,7 +42,10 @@ semver = "1.0"
regex = "1"
log = "0.4"
env_logger = "0.11"
clap = "4.4"
clap = "4.5"
serde = "1.0"
serde_json = "1.0"
toml = "0.8"
futures-lite = "2.3"
thiserror = "1.0"
tokio = { version = "1.37", default-features = false }
19 changes: 13 additions & 6 deletions cargo/Cargo.toml
Original file line number Diff line number Diff line change
@@ -31,9 +31,10 @@ clap_lex = "0.7"
dirs.workspace = true
fs_extra.workspace = true

cargo = "=0.77.0"
cargo-util = "=0.2.9"
cargo-platform = "0.1.7"
cargo = "0.78.0"
cargo-util = "0.2.10"
cargo-platform = "0.1.8"
cargo-util-schemas = "0.2.0"

semver.workspace = true
serde = { workspace = true, features = ["derive"] }
@@ -51,14 +52,21 @@ anstyle = "1"
env_logger.workspace = true
log.workspace = true

futures-lite.workspace = true


[dependencies.build]
workspace = true
default-features = false
features = ["assets-report", "toml"]

[dependencies.tool]
[dependencies.device]
workspace = true
features = ["clap", "async", "tokio-serial", "tokio"]

[dependencies.simulator]
# features = ["tokio"]
workspace = true
features = ["clap", "cli"]

[dependencies.clap]
workspace = true
@@ -89,4 +97,3 @@ nix = { version = "0.28", features = ["signal"] }

[features]
default = []
usb = ["tool/usb"]
2 changes: 1 addition & 1 deletion cargo/src/build/plan/format.rs
Original file line number Diff line number Diff line change
@@ -73,7 +73,7 @@ pub enum CompileModeProxy {
Bench,
/// A target that will be documented with `rustdoc`.
/// If `deps` is true, then it will also document all dependencies.
Doc { deps: bool },
Doc { deps: bool, json: bool },
/// A target that will be tested with `rustdoc`.
Doctest,
/// An example or library that will be scraped for function calls by `rustdoc`.
5 changes: 2 additions & 3 deletions cargo/src/cli/mod.rs
Original file line number Diff line number Diff line change
@@ -20,7 +20,6 @@ use cargo::util::command_prelude::{ArgMatchesExt, CompileMode, ProfileChecking};
use cargo::util::Config as CargoConfig;
use cargo::util::CargoResult;
use clap_lex::SeekFrom;
use tool::cli::mount::Mount;

use crate::config::Config;
use crate::logger::LogErr;
@@ -220,7 +219,7 @@ pub fn initialize_from(args: impl IntoIterator<Item = impl Into<OsString> + AsRe
log::debug!("extra args: {extra:?}");
}

let no_wait = matches.flag("no-wait");
let no_read = matches.flag("no-read");
let mounting = matches!(cmd, Cmd::Run).then(|| Mount::from_arg_matches(&matches).ok())
.flatten();

@@ -289,7 +288,7 @@ pub fn initialize_from(args: impl IntoIterator<Item = impl Into<OsString> + AsRe
sdk_path,
gcc_path,
mounting,
no_wait,
no_read,
zip,
no_info_meta,
prevent_unwinding,
21 changes: 13 additions & 8 deletions cargo/src/cli/opts.rs
Original file line number Diff line number Diff line change
@@ -8,8 +8,7 @@ use clap::{Arg, ArgAction, value_parser, Args};
use clap::Command;
use playdate::consts::{SDK_ENV_VAR, DEVICE_TARGET};
use playdate::toolchain::gcc::ARM_GCC_PATH_ENV_VAR;
use tool::cli::run::DeviceDestination;
use tool::cli::mount::Mount;
// use tool::cli::mount::Mount;


use super::{Cmd, CMD_NAME, BIN_NAME};
@@ -48,7 +47,7 @@ pub fn special_args_for(cmd: &Cmd) -> Vec<Arg> {
Cmd::Run => {
let mut args = mount();
args.append(&mut shorthands_for(cmd));
args.push(flag_no_wait());
args.push(flag_no_read());
args.push(flag_no_info_file());
args.push(flag_pdc_skip_unknown());
args.push(flag_pdc_skip_prebuild());
@@ -231,6 +230,12 @@ fn init_crate() -> Command {
}


#[derive(clap::Parser, Debug, Clone, Default)]
pub struct Mount {
#[command(flatten)]
pub device: device::device::query::Query,
}

fn mount() -> Vec<Arg> {
let mount: Command =
Mount::augment_args(Command::new("mount")).mut_arg("device", |arg| arg.long("device").num_args(0..=1));
@@ -240,11 +245,11 @@ fn mount() -> Vec<Arg> {
.collect()
}

fn flag_no_wait() -> Arg {
DeviceDestination::augment_args(Command::new("dest")).get_arguments()
.find(|arg| arg.get_id().as_str() == "no-wait")
.expect("Arg no-wait")
.to_owned()
fn flag_no_read() -> Arg {
flag(
"no-read",
"Do not wait & read the device's output after execution.",
)
}


8 changes: 4 additions & 4 deletions cargo/src/config.rs
Original file line number Diff line number Diff line change
@@ -7,7 +7,6 @@ use cargo::core::compiler::{CompileTarget, CompileKind, TargetInfo};
use cargo::ops::CompileOptions;
use playdate::toolchain::gcc::{ArmToolchain, Gcc};
use playdate::toolchain::sdk::Sdk;
use tool::cli::mount::Mount;
use try_lazy_init::Lazy;

use cargo::util::{CargoResult, Rustc};
@@ -16,6 +15,7 @@ use crate::build::rustflags::Rustflags;
use crate::cli::cmd::Cmd;
use crate::cli::deps::Dependency;
use crate::cli::ide::Ide;
use crate::cli::opts::Mount;
use crate::utils::LazyBuildContext;


@@ -39,7 +39,7 @@ pub struct Config<'cfg> {
pub gcc_path: Option<PathBuf>,

pub mounting: Option<Mount>,
pub no_wait: bool,
pub no_read: bool,

pub zip: bool,
pub no_info_meta: bool,
@@ -83,7 +83,7 @@ impl<'cfg> Config<'cfg> {
sdk_path: Option<PathBuf>,
gcc_path: Option<PathBuf>,
mounting: Option<Mount>,
no_wait: bool,
no_read: bool,
zip: bool,
no_info_meta: bool,
prevent_unwinding: bool,
@@ -111,7 +111,7 @@ impl<'cfg> Config<'cfg> {
sdk_path,
gcc_path,
mounting,
no_wait,
no_read,
zip,
no_info_meta,
prevent_unwinding,
28 changes: 14 additions & 14 deletions cargo/src/main.rs
Original file line number Diff line number Diff line change
@@ -203,22 +203,22 @@ fn execute(config: &Config) -> CargoResult<()> {

// run:
{
use tool::cli::run::run;
use tool::cli::run::{Run, Destination, SimDestination, DeviceDestination};
use tool::cli::install::Install;

let destination = if ck.is_playdate() {
Destination::Device(DeviceDestination { install: Install { pdx: package.path.to_owned(),
mount: config.mounting
.clone()
.unwrap_or_default() },
no_install: false,
no_wait: config.no_wait })
use futures_lite::future::block_on;
use device::run::run as run_dev;
use simulator::run::run as run_sim;

if ck.is_playdate() {
let query = config.mounting.clone().unwrap_or_default().device;
let pdx = package.path.to_owned();
let no_install = false;
let no_read = config.no_read;
let force = false;
let fut = run_dev(query, pdx, no_install, no_read, force);
block_on(fut)?;
} else {
Destination::Simulator(SimDestination { pdx: package.path.to_owned() })
let fut = run_sim(&package.path, config.sdk_path.as_deref());
block_on(fut)?;
};

run(Run { destination })?;
}

std::process::exit(0)
2 changes: 1 addition & 1 deletion cargo/src/package/mod.rs
Original file line number Diff line number Diff line change
@@ -6,13 +6,13 @@ use std::process::Command;

use anyhow::anyhow;
use anyhow::bail;
use cargo::util_schemas::manifest::TomlDebugInfo;
use cargo::CargoResult;
use cargo::core::Package;
use cargo::core::compiler::CompileKind;
use cargo::core::compiler::CrateType;
use cargo::core::profiles::DebugInfo;
use cargo::core::profiles::Profiles;
use cargo_util_schemas::manifest::TomlDebugInfo;
use clap_lex::OsStrExt;
use playdate::io::soft_link_checked;
use playdate::layout::Layout;
3 changes: 2 additions & 1 deletion rust-toolchain.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
[toolchain]
channel = "nightly-2023-11-10"
# channel = "nightly-2023-11-10"
channel = "nightly"
profile = "minimal"
targets = ["thumbv7em-none-eabihf"]
components = [
2 changes: 1 addition & 1 deletion support/bindgen/Cargo.toml
Original file line number Diff line number Diff line change
@@ -32,7 +32,7 @@ utils.workspace = true
bindgen-cfg = { workspace = true, features = ["clap"] }

[dependencies.bindgen]
version = "=0.69.4"
version = "0.69.4"
default-features = false

[dependencies.clap]
80 changes: 80 additions & 0 deletions support/device/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
[package]
name = "playdate-device"
version = "0.1.0"
readme = "README.md"
description = "Cross-platform interface Playdate device, async & blocking."
keywords = ["playdate", "usb", "serial"]
categories = ["hardware-support"]
edition.workspace = true
license.workspace = true
authors.workspace = true
homepage.workspace = true
repository.workspace = true


[dependencies]
object-pool = "0.5"

regex.workspace = true
log.workspace = true
miette = "7.2"
thiserror.workspace = true

nusb = "0.1"
usb-ids = { version = "1.2024.2" }
serialport = { version = "4.3", features = ["usbportinfo-interface"] }
tokio-serial = { version = "5.4", optional = true }

tracing = { version = "0.1", optional = true }

# mb. read mount-points more correctly:
# rustix = "0.38"

serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
hex = "0.4"

[dependencies.tokio]
features = ["fs", "process", "time", "io-std"]
workspace = true
optional = true

[dependencies.futures-lite]
version = "2.3"

[dependencies.futures]
version = "0.3"
optional = true


[dependencies.clap]
features = ["std", "env", "derive", "help", "color"]
workspace = true
optional = true


[target.'cfg(target_os = "macos")'.dependencies]
plist = "1.6"
const-hex = "1.11"

[target.'cfg(target_os = "linux")'.dependencies]
udev = "0.8"
lfs-core = "0.11"

[target.'cfg(target_os = "windows")'.dependencies.windows]
version = "0.54.0"
features = [
"Win32_Foundation",
"Win32_Storage",
"Win32_Storage_FileSystem",
"Win32_System",
"Win32_System_IO",
"Win32_System_Ioctl",
"Win32_Security",
]


[features]
default = ["async"]
async = ["futures", "tokio", "tokio-serial"]
tokio-serial = ["dep:tokio-serial", "tokio?/io-util", "tokio?/rt"]
43 changes: 43 additions & 0 deletions support/device/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# [Playdate][playdate-website] device support library

Cross-platform interface for Playdate device, async & blocking.

Contains methods for:
- find connected devices, filter by mode, state, serial-number
- send commands
- read from devices
- mount as drive (mass storage usb)
- unmount
- install pdx (playdate package)
- run pdx (optionally with install before run)
- operate with multiple devices simultaneously


### Status

This crate in active development and API can be changed in future versions, with minor version increment.

Supported platforms:
- MacOs
- Linux
- Windows


## Prerequisites

1. Rust __nightly__ toolchain
2. Linux only:
- `libudev`, follow [instructions for udev crate][udev-crate-deps].



[playdate-website]: https://play.date
[udev-crate-deps]: https://crates.io/crates/udev#Dependencies





- - -

This software is not sponsored or supported by Panic.
254 changes: 254 additions & 0 deletions support/device/src/device/command.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
use std::borrow::Cow;
use std::fmt::Display;
use std::io::Write;
use std::path::Path;
use std::path::PathBuf;


#[derive(Debug, Clone)]
#[cfg_attr(feature = "clap", derive(clap::Subcommand))]
#[cfg_attr(feature = "clap", command(name = "COMMAND"))]
pub enum Command {
/// Run custom pdx.
Run {
/// On-device path to the PDX package,
/// e.g. `/Games/MyGame.pdx` or `/System/Settings.pdx`
path: String,
},

/// Run system built-in pdx,
#[cfg_attr(feature = "clap", command(name = "run-sys"))]
RunSystem {
/// System built-in application,
#[cfg_attr(feature = "clap", arg(value_name = "NAME"))]
path: SystemPath,
},

/// Reboot into data segment USB disk
Datadisk,

/// Hibernate, semi-deep sleep mode.
#[cfg_attr(feature = "clap", command(visible_alias = "sleep"))]
Hibernate,

/// Turn console echo on or off.
Echo {
#[cfg_attr(feature = "clap", arg(default_value_t = Switch::On))]
value: Switch,
},

/// Request the device serial number.
#[cfg_attr(feature = "clap", command(visible_alias = "sn"))]
SerialNumber,

/// Request the device version info.
#[cfg_attr(feature = "clap", command(visible_alias = "V"))]
Version,

/// Simulate a button press.
///
/// +a/-a/a for down/up/both
#[cfg_attr(feature = "clap", command(visible_alias = "btn"))]
Button {
/// Button to press or release.
#[cfg_attr(feature = "clap", clap(subcommand))]
button: Button,
},

/// Send a message to a message handler in the current running program.
#[cfg_attr(feature = "clap", command(visible_alias = "msg"))]
Message {
/// Message to send.
message: String,
},

/// Send custom command.
#[cfg_attr(feature = "clap", command(visible_alias = "!"))]
Custom {
/// Command to send.
cmd: String,
},
}


#[derive(Debug, Clone)]
#[cfg_attr(feature = "clap", derive(clap::Parser))]
pub enum PdxPath {
System { path: SystemPath },
User { path: PathBuf },
}


#[derive(Debug, Clone)]
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
pub enum SystemPath {
/// Launcher application, Home.
///
/// `/System/Launcher.pdx`.
Launcher,
/// Settings application.
///
/// `/System/Settings.pdx`.
Settings,
/// Playdate Catalog application.
///
/// `/System/Catalog.pdx`.
Catalog,
}

impl SystemPath {
pub fn as_path(&self) -> &Path {
match self {
Self::Launcher => Path::new("/System/Launcher.pdx"),
Self::Settings => Path::new("/System/Settings.pdx"),
Self::Catalog => Path::new("/System/Catalog.pdx"),
}
}
}


impl Command {
pub fn as_str(&self) -> Cow<'_, str> {
match self {
Command::Run { path } => format!("run {path}").into(),
Command::RunSystem { path } => format!("run {}", path.as_path().display()).into(),
Command::Datadisk => "datadisk".into(),
Command::Hibernate => "hibernate".into(),
Command::Echo { value: Switch::On } => "echo on".into(),
Command::Echo { value: Switch::Off } => "echo off".into(),
Command::SerialNumber => "serialread".into(),
Command::Version => "version".into(),
Command::Button { button } => format!("btn {}", button.as_btn_str()).into(),
Command::Message { message } => format!("msg {message}").into(),
Command::Custom { cmd } => format!("{cmd}").into(),
}
}
}


impl Display for Command {
#[inline]
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.as_str().fmt(f) }
}

impl Command {
pub fn with_break(&self) -> String {
let cmd = self.as_str();
let mut line = String::with_capacity(cmd.len() + 2);
line.push('\n'); // extra break to ensure that the command starts from new line.
line.push_str(&cmd);
line.push('\n');
line
}

pub fn with_break_to<W: Write>(&self, mut writer: W) -> std::io::Result<()> { writeln!(writer, "\n{self}\n") }
}


#[derive(Debug, Clone, Copy)]
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
#[cfg_attr(feature = "clap", clap(name = "BOOL"))]
pub enum Switch {
/// Turn echo on.
/// [aliases: true]
#[cfg_attr(feature = "clap", value(alias = "true"))]
On,
/// Turn echo off.
/// [aliases: false]
#[cfg_attr(feature = "clap", value(alias = "false"))]
Off,
}

impl From<bool> for Switch {
fn from(value: bool) -> Self { if value { Switch::On } else { Switch::Off } }
}

impl Into<bool> for Switch {
fn into(self) -> bool {
match self {
Switch::On => true,
Switch::Off => false,
}
}
}

impl std::fmt::Display for Switch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let value = match self {
Self::On => "on",
Self::Off => "off",
};
write!(f, "{value}")
}
}


#[derive(Debug, Clone, Copy)]
#[cfg_attr(feature = "clap", derive(clap::Subcommand))]
#[cfg_attr(feature = "clap", command(name = "BTN"))]
pub enum Button {
A {
#[cfg_attr(feature = "clap", arg(required = false, default_value_t = ButtonAction::Both))]
action: ButtonAction,
},
B {
#[cfg_attr(feature = "clap", arg(required = false, default_value_t = ButtonAction::Both))]
action: ButtonAction,
},
}

impl std::fmt::Display for Button {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Button::A { action } => write!(f, "{action}a"),
Button::B { action } => write!(f, "{action}b"),
}
}
}

impl Button {
pub fn as_btn_str(&self) -> String {
match self {
Button::A { action } => format!("{}a", action.as_btn_prefix()),
Button::B { action } => format!("{}b", action.as_btn_prefix()),
}
}
}


#[derive(Debug, Clone, Copy)]
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
#[cfg_attr(feature = "clap", clap(name = "BTN"))]
pub enum ButtonAction {
#[cfg_attr(feature = "clap", value(alias = "-"))]
Down,
#[cfg_attr(feature = "clap", value(alias = "+"))]
Up,
#[cfg_attr(feature = "clap", value(alias = "+-"), value(alias = "±"))]
Both,
}

impl Default for ButtonAction {
fn default() -> Self { Self::Both }
}

impl std::fmt::Display for ButtonAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let value = match self {
Self::Down => "-",
Self::Up => "+",
Self::Both => "±",
};
write!(f, "{value}")
}
}

impl ButtonAction {
pub fn as_btn_prefix(&self) -> &'static str {
match self {
Self::Down => "+",
Self::Up => "-",
Self::Both => "",
}
}
}
63 changes: 63 additions & 0 deletions support/device/src/device/methods.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
#![cfg(feature = "tokio")]


use crate::retry::{IterTime, Retries};
use crate::usb::mode::Mode;
use crate::error::Error;

use super::Device;


type Result<T = (), E = Error> = std::result::Result<T, E>;


pub async fn wait_mode_storage<T>(dev: Device, retry: Retries<T>) -> Result<Device>
where T: IterTime {
wait_mode_change(dev, Mode::Storage, retry).await
}

pub async fn wait_mode_data<T>(dev: Device, retry: Retries<T>) -> Result<Device>
where T: IterTime {
wait_mode_change(dev, Mode::Data, retry).await
}


#[cfg_attr(feature = "tracing", tracing::instrument(skip(dev, retry), fields(dev = dev.info().serial_number())))]
pub async fn wait_mode_change(mut dev: Device, to: Mode, retry: Retries<impl IterTime>) -> Result<Device> {
let total = &retry.total;
let iter_ms = retry.iters.interval(total);
let retries_num = total.as_millis() / iter_ms.as_millis();
debug!("retries: {retries_num} * {iter_ms:?} ≈ {total:?}.");

let mut counter = retries_num;
let mut interval = tokio::time::interval(iter_ms);

while {
counter -= 1;
counter
} != 0
{
interval.tick().await;

let mode = dev.mode_cached();
trace!(
"{dev}: waiting mode {to}, current: {mode}, try: {}/{retries_num}",
retries_num - counter
);

if mode == to {
dev.info().serial_number().map(|s| trace!("{s} is in {to} mode."));
return Ok(dev);
}

if dev.refresh()? {
if dev.mode_cached() == to {
return Ok(dev);
} else {
trace!("refreshed to {mode} mode, waiting...")
}
}
}

Err(Error::usb_timeout(dev))
}
108 changes: 108 additions & 0 deletions support/device/src/device/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
use std::hash::Hash;
pub use nusb::DeviceInfo;
use crate::usb::mode::DeviceMode;
use crate::usb::mode::Mode;

pub mod serial;
pub mod query;
pub mod command;

mod methods;
pub use methods::*;


/// USB device wrapper
pub struct Device {
// pub serial: Option<SerialNumber>,
pub(crate) info: DeviceInfo,
pub(crate) mode: Mode,

/// Opened device handle
pub(crate) inner: Option<nusb::Device>,

// /// Claimed bulk data interface
// pub(crate) bulk: Option<crate::usb::Interface>,

// /// Opened serial fallback interface
// pub(crate) serial: Option<crate::serial::blocking::Interface>,
pub(crate) interface: Option<crate::interface::Interface>,
}

impl Eq for Device {}
impl PartialEq for Device {
fn eq(&self, other: &Self) -> bool {
self.info.serial_number().is_some() && self.info.serial_number() == other.info.serial_number()
}
}

impl Hash for Device {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
let info = &self.info;
info.serial_number().hash(state);
info.bus_number().hash(state);
info.device_address().hash(state);
info.vendor_id().hash(state);
info.product_id().hash(state);
info.class().hash(state);
info.subclass().hash(state);
info.protocol().hash(state);
info.manufacturer_string().hash(state);
info.product_string().hash(state);
self.inner.is_some().hash(state);
self.interface.is_some().hash(state);
}
}


impl AsRef<DeviceInfo> for Device {
fn as_ref(&self) -> &DeviceInfo { &self.info }
}

impl std::fmt::Display for Device {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}({})",
self.info.serial_number().unwrap_or("unknown"),
self.info.mode()
)
}
}

impl std::fmt::Debug for Device {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Device")
.field("sn", &self.info.serial_number())
.field("mode", &self.mode)
.field("open", &self.is_open())
.field("interface", &self.interface)
.finish()
}
}


impl Device {
pub fn new(info: DeviceInfo) -> Self {
Self { mode: info.mode(),
info,
inner: None,
interface: None }
}

pub fn info(&self) -> &DeviceInfo { &self.info }
pub fn into_info(self) -> DeviceInfo { self.info }


// USB

/// Cached mode of this device
pub fn mode_cached(&self) -> Mode { self.mode }
pub fn is_open(&self) -> bool { self.inner.is_some() || self.is_ready() }
pub fn is_ready(&self) -> bool {
match self.interface.as_ref() {
Some(crate::interface::Interface::Usb(_)) => true,
Some(crate::interface::Interface::Serial(inner)) => inner.is_open(),
None => false,
}
}
}
139 changes: 139 additions & 0 deletions support/device/src/device/query.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
use std::path::Path;
use std::path::PathBuf;
use std::str::FromStr;

use super::serial::SerialNumber;


pub const DEVICE_SERIAL_ENV: &str = "PLAYDATE_SERIAL_DEVICE";


/// Device query. Contains 4 options:
/// - None: query all devices
/// - Serial: query by serial number
/// - Path: query by path/name of serial port
/// - Com: query by COM port number (windows only)
#[derive(Clone)]
#[cfg_attr(feature = "clap", derive(clap::Parser))]
#[cfg_attr(feature = "clap", command(author, version, about, long_about = None, name = "device"))]
pub struct Query {
/// Serial number of usb device or absolute path to socket.
/// Format: 'PDUN-XNNNNNN'
#[cfg_attr(unix, doc = "or '/dev/cu.usbmodemPDUN_XNNNNNN(N)'.")]
#[cfg_attr(windows, doc = "or 'COM{X}', where {X} is a number of port, e.g.: COM3.")]
#[cfg_attr(feature = "clap", arg(env = DEVICE_SERIAL_ENV, name = "device"))]
pub value: Option<Value>,
}

impl Default for Query {
fn default() -> Self {
Self { value: std::env::var(DEVICE_SERIAL_ENV).map(|s| Value::from_str(&s).ok())
.ok()
.flatten() }
}
}

impl std::fmt::Display for Query {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.value {
Some(ref value) => value.fmt(f),
None => write!(f, "None"),
}
}
}

impl std::fmt::Debug for Query {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.value.as_ref() {
Some(value) => f.debug_tuple("Query").field(&value.to_string()).finish(),
None => f.debug_tuple("Query").field(&None::<()>).finish(),
}
}
}

impl std::fmt::Display for Value {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Value::Serial(sn) => write!(f, "sn:{sn}"),
Value::Path(path) => write!(f, "serial:{}", path.display()),
Value::Com(port) => write!(f, "serial:COM{port}"),
}
}
}


#[derive(Clone, Debug)]
pub enum Value {
/// Serial number of usb device.
Serial(SerialNumber),
/// Absolute path of serial/modem/telnet-socket.
///
/// In case of unmounting or installing it also can by mount-point.
#[cfg_attr(not(unix), doc = "Use only on Unix.")]
Path(PathBuf),
/// COM port.
#[cfg_attr(not(windows), doc = "Use only on Windows.")]
Com(u16),
}

type ParseError = <SerialNumber as FromStr>::Err;
impl FromStr for Value {
type Err = crate::error::Error;

fn from_str(dev: &str) -> Result<Self, Self::Err> {
let name = dev.trim();
if name.is_empty() {
return Err(ParseError::from(name).into());
}

#[cfg(windows)]
match name.strip_prefix("COM").map(|s| s.parse::<u16>()) {
Some(Ok(com)) => return Ok(Value::Com(com)),
Some(Err(err)) => {
use std::io::{Error, ErrorKind};

return Err(Error::new(
ErrorKind::InvalidInput,
format!("Invalid format, seems to COM port, but {err}."),
).into());
},
None => { /* nothing there */ },
}

let serial = SerialNumber::try_from(name);
let path = Path::new(name);
let is_direct = path.is_absolute() && path.exists();

match serial {
Ok(serial) => {
if is_direct {
Ok(Value::Path(path.to_owned()))
} else {
Ok(Value::Serial(serial))
}
},
Err(err) => {
if is_direct {
Ok(Value::Path(path.to_owned()))
} else {
Err(err.into())
}
},
}
}
}

impl<'s> TryFrom<&'s str> for Value {
type Error = crate::error::Error;
fn try_from(dev: &'s str) -> Result<Self, Self::Error> { Self::from_str(dev) }
}

impl Value {
pub fn to_printable_string(&self) -> String {
match self {
Self::Serial(sn) => sn.to_string(),
Self::Path(p) => p.display().to_string(),
Self::Com(n) => format!("COM{n}"),
}
}
}
Original file line number Diff line number Diff line change
@@ -9,12 +9,6 @@ use regex::Regex;
#[derive(Clone)]
pub struct SerialNumber(String);

const UNKNOWN: &str = "UNKNOWN";

impl SerialNumber {
pub(crate) fn unknown() -> Self { Self(UNKNOWN.to_string()) }
}


impl SerialNumber {
pub fn contained_in<S: AsRef<str>>(s: S) -> Option<Self> {
@@ -45,10 +39,11 @@ impl SerialNumber {
impl FromStr for SerialNumber {
type Err = DeviceSerialFormatError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::contained_in(s).ok_or(DeviceSerialFormatError(s.to_string()))
Self::contained_in(s).ok_or_else(|| DeviceSerialFormatError::from(s))
}
}


impl TryFrom<String> for SerialNumber {
type Error = <Self as FromStr>::Err;
fn try_from(value: String) -> Result<Self, Self::Error> { Self::from_str(value.as_str()) }
@@ -66,8 +61,13 @@ impl TryFrom<&Path> for SerialNumber {


impl PartialEq for SerialNumber {
fn eq(&self, other: &Self) -> bool {
(self.0 != UNKNOWN && other.0 != UNKNOWN) && (self.0.contains(&other.0) || other.0.contains(&self.0))
fn eq(&self, other: &Self) -> bool { self.0.contains(&other.0) || other.0.contains(&self.0) }
}

impl<T: AsRef<str>> PartialEq<T> for SerialNumber {
fn eq(&self, other: &T) -> bool {
let other = other.as_ref().to_uppercase();
self.0.contains(&other) || other.contains(&self.0)
}
}

@@ -82,24 +82,30 @@ impl std::fmt::Display for SerialNumber {
}


#[derive(Debug)]
pub struct DeviceSerialFormatError(String);
impl std::error::Error for DeviceSerialFormatError {}
use std::backtrace::Backtrace;
use thiserror::Error;
use miette::Diagnostic;

impl std::fmt::Display for DeviceSerialFormatError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Invalid serial number format: {}, should be PDUN-XNNNNNN",
self.0
)

#[derive(Error, Debug, Diagnostic)]
#[error("Invalid serial number: {value}, expected format: PDUN-XNNNNNN.")]
pub struct DeviceSerialFormatError {
pub value: String,
#[backtrace]
backtrace: Backtrace,
}

impl DeviceSerialFormatError {
fn new(value: String) -> Self {
Self { value,
backtrace: Backtrace::capture() }
}
}

impl From<String> for DeviceSerialFormatError {
fn from(value: String) -> Self { Self(value) }
fn from(value: String) -> Self { Self::new(value) }
}

impl From<&str> for DeviceSerialFormatError {
fn from(value: &str) -> Self { Self(value.to_owned()) }
fn from(value: &str) -> Self { Self::new(value.to_owned()) }
}
177 changes: 177 additions & 0 deletions support/device/src/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
use std::backtrace::Backtrace;
use thiserror::Error;
use miette::Diagnostic;
use crate::usb::mode::Mode;


#[derive(Error, Debug, Diagnostic)]
pub enum Error {
#[error(transparent)]
#[diagnostic(code(my_lib::io_error))]
Io {
#[backtrace]
#[from]
source: std::io::Error,
},

#[error(transparent)]
#[diagnostic()]
Process {
#[backtrace]
#[from]
source: std::process::ExitStatusError,
},

#[error(transparent)]
#[diagnostic()]
Transfer {
#[backtrace]
#[from]
source: nusb::transfer::TransferError,
},

#[error(transparent)]
#[diagnostic()]
Borrow {
#[backtrace]
#[from]
source: std::cell::BorrowMutError,
},

#[error("Awaiting device timeout `{device}`.")]
#[diagnostic()]
DeviceTimeout {
#[backtrace]
backtrace: Backtrace,
device: crate::device::Device,
},

#[error("Awaiting {what} timeout.")]
Timeout {
#[backtrace]
backtrace: Backtrace,
what: String,
},

#[error(transparent)]
#[diagnostic()]
Utf {
#[backtrace]
#[from]
source: std::str::Utf8Error,
},

#[error(transparent)]
Json {
#[backtrace]
#[from]
source: serde_json::Error,
},

#[cfg(target_os = "macos")]
#[error(transparent)]
Plist {
#[backtrace]
#[from]
source: plist::Error,
},

#[cfg(target_os = "linux")]
#[error(transparent)]
Lfs {
#[backtrace]
#[from]
source: lfs_core::Error,
},

#[cfg(target_os = "windows")]
#[error(transparent)]
WinApi {
#[backtrace]
#[from]
source: windows::core::Error,
},

#[error("Chain of errors: {source}\n\t{others:#?}")]
#[diagnostic()]
Chain {
#[backtrace]
#[diagnostic(transparent)]
source: Box<Error>,
#[related]
#[diagnostic(transparent)]
others: Vec<Error>,
},

#[error(transparent)]
#[diagnostic(transparent)]
DeviceSerial {
#[backtrace]
#[from]
source: crate::device::serial::DeviceSerialFormatError,
},

#[error(transparent)]
#[diagnostic()]
SerialPort {
#[backtrace]
#[from]
source: serialport::Error,
},

#[diagnostic()]
#[error("Device not found.")]
/// Device discovery error.
NotFound(#[backtrace] Backtrace),

#[diagnostic()]
#[error("Interface not ready.")]
/// Interface error.
NotReady(#[backtrace] Backtrace),

#[error("Device in the wrong state `{0:?}`.")]
WrongState(Mode),

#[error("Mount point not found for {0}.")]
MountNotFound(String),
// #[error("data store disconnected")]
// Disconnect(#[from] io::Error),
// #[error("the data for key `{0}` is not available")]
// Redaction(String),
// #[error("invalid header (expected {expected:?}, found {found:?})")]
// InvalidHeader { expected: String, found: String },
// #[error("unknown error")]
// Unknown,
}


impl Error {
#[track_caller]
pub fn usb_timeout(device: crate::device::Device) -> Self {
Self::DeviceTimeout { device,
backtrace: Backtrace::capture() }
}

#[track_caller]
pub fn timeout<S: ToString>(what: S) -> Self {
Self::Timeout { what: what.to_string(),
backtrace: Backtrace::capture() }
}

#[track_caller]
pub fn not_found() -> Self { Self::NotFound(Backtrace::capture()) }
#[track_caller]
pub fn not_ready() -> Self { Self::NotReady(Backtrace::capture()) }

#[track_caller]
pub fn chain<I, A, B>(err: A, others: I) -> Self
where I: IntoIterator<Item = B>,
A: Into<Error>,
B: Into<Error> {
Self::Chain { source: Box::new(err.into()),
others: others.into_iter().map(Into::into).collect() }
}
}


unsafe impl Sync for Error {}
191 changes: 191 additions & 0 deletions support/device/src/install.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::time::Duration;

use futures::{FutureExt, Stream, StreamExt, TryFutureExt};

use crate::device::query::Query;
use crate::error::Error;
use crate::mount::MountedDevice;
use crate::mount;
use crate::retry::Retries;


type Result<T = (), E = Error> = std::result::Result<T, E>;


/// On-device path with owned mounted device.
pub struct MountedDevicePath {
drive: MountedDevice,
path: String,
}

/// On-device path with borrowed mounted device.
pub struct MountedDevicePathBorrowed<'dev> {
drive: &'dev MountedDevice,
path: String,
}

impl<'dev> MountedDevicePathBorrowed<'dev> {
pub fn drive(&self) -> &MountedDevice { &self.drive }

/// Local on-device path.
pub fn path_local(&self) -> &str { &self.path }
/// Absolute on-host path.
pub fn path_abs(&self) -> PathBuf { self.drive.handle.path().join(&self.path) }

pub fn into_path(self) -> String { self.path }
pub fn into_parts(self) -> (&'dev MountedDevice, String) { (self.drive, self.path) }

pub fn to_owned_replacing(self) -> impl FnOnce(MountedDevice) -> MountedDevicePath {
let (_, path) = self.into_parts();
move |drive| MountedDevicePath { drive, path }
}
}

impl MountedDevicePath {
pub fn drive(&self) -> &MountedDevice { &self.drive }
pub fn drive_mut(&mut self) -> &mut MountedDevice { &mut self.drive }

/// Local on-device path.
pub fn path_local(&self) -> &str { &self.path }
/// Absolute on-host path.
pub fn path_abs(&self) -> PathBuf { self.drive.handle.path().join(&self.path) }

pub fn into_path(self) -> String { self.path }
pub fn into_parts(self) -> (MountedDevice, String) { (self.drive, self.path) }
}


/// Install package on the device.
///
/// `path` is a host filesystem path to pdx.
#[cfg_attr(feature = "tracing", tracing::instrument(skip(drive)))]
pub async fn install<'dev>(drive: &'dev MountedDevice,
path: &Path,
force: bool)
-> Result<MountedDevicePathBorrowed<'dev>> {
#[cfg(feature = "tokio")]
use tokio::process::Command;
#[cfg(not(feature = "tokio"))]
use std::process::Command;


let retry = Retries::new(Duration::from_millis(500), Duration::from_secs(60));
mount::wait_fs_available(drive, retry).await?;
validate_host_package(path).await?;

trace!(
"Installing: {} -> {}",
path.display(),
drive.handle.path().display()
);

let games = drive.handle.path().join("Games");

let cp = || {
async {
if cfg!(unix) {
let mut cmd = Command::new("cp");

if force {
cmd.arg("-r");
}

cmd.arg(path);
cmd.arg(&games);

#[cfg(feature = "tokio")]
cmd.status().await?.exit_ok()?;
#[cfg(not(feature = "tokio"))]
cmd.status()?.exit_ok()?;
} else if cfg!(windows) {
// xcopy c:\test c:\test2\test /S /E /H /I /Y
let mut cmd = Command::new("xcopy");
cmd.arg(path);
cmd.arg(games.join(path.file_name().unwrap()));

cmd.args(["/S", "/E", "/H", "/I"]);
if force {
cmd.arg("/Y");
}

#[cfg(feature = "tokio")]
cmd.status().await?.exit_ok()?;
#[cfg(not(feature = "tokio"))]
cmd.status()?.exit_ok()?;
} else {
unreachable!("Unsupported OS")
}
Ok::<_, Error>(())
}
};

if !path.is_dir() {
#[cfg(feature = "tokio")]
{
tokio::fs::copy(path, games.join(path.file_name().unwrap())).map_ok(|bytes| trace!("copied {bytes}"))
.inspect_err(|err| error!("{err}"))
.or_else(|_| cp())
.await?;
};
#[cfg(not(feature = "tokio"))]
{
std::fs::copy(path, games.join(path.file_name().unwrap())).map(|bytes| trace!("copied {bytes}"))
.inspect_err(|err| error!("{err}"))
.or_else(|_| {
futures_lite::future::block_on(cp())
})?;
}
} else {
cp().await?;
}

// on-dev-path:
let path = format!("/Games/{}", path.file_name().unwrap().to_string_lossy());
Ok(MountedDevicePathBorrowed { drive, path })
}


/// 1. Mount if needed
/// 1. Wait for FS to become available
/// 1. Install package
#[cfg_attr(feature = "tracing", tracing::instrument())]
pub async fn mount_and_install(query: Query,
path: &Path,
force: bool)
-> Result<impl Stream<Item = Result<MountedDevicePath>> + '_> {
validate_host_package(path).await?;

// TODO: Check query is path and this is mounted volume.

let fut = mount::mount_and(query, true).await?.flat_map(move |res| {
async move {
match res {
Ok(drive) => {
let path = install(&drive, path, force).await?;
Ok(path.to_owned_replacing()(drive))
},
Err(err) => Err(err),
}
}.into_stream()
});
Ok(fut)
}


/// Validate path - pdz or pdx-dir.
#[cfg_attr(feature = "tracing", tracing::instrument())]
pub async fn validate_host_package(path: &Path) -> Result<()> {
use std::io::{Error, ErrorKind};

if !path.try_exists()? {
return Err(Error::new(ErrorKind::NotFound, "package not found").into());
}

(path.is_dir() ||
path.extension() == Some(OsStr::new("pdz")) ||
path.extension() == Some(OsStr::new("pdx")))
.then_some(())
.ok_or_else(|| Error::new(ErrorKind::InvalidData, "invalid package").into())
}
62 changes: 62 additions & 0 deletions support/device/src/interface/async.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
use std::future::Future;

use crate::device::command::Command;
use crate::error::Error;


pub trait Out: In {
// type Error: std::error::Error;

fn send(&self, data: &[u8]) -> impl Future<Output = Result<usize, Error>>;

fn send_cmd(&self, cmd: Command) -> impl Future<Output = Result<usize, Error>> {
async move {
let mut pre = 0;
if !matches!(cmd, Command::Echo { .. }) {
use crate::device::command::Switch;

trace!("send cmd: echo off");
let echo = Command::Echo { value: Switch::Off };
pre = self.send(echo.with_break().as_bytes()).await?;
}

trace!("send cmd: {cmd}");
let sent = self.send(cmd.with_break().as_bytes()).await?;
Ok(pre + sent)
}
}
}

pub trait In {
// type Error: std::error::Error = crate::error::Error;
}

pub trait Interface: Out {}
impl<T: In + Out> Interface for T {}


// pub trait AsyncSend {
// fn send_cmd(&mut self,
// cmd: crate::device::command::Command)
// -> impl std::future::Future<Output = Result<usize, Error>>;
// }


// mod ext {
// use super::*;


// impl<T> AsyncSend for T
// where T: tokio::io::AsyncWriteExt,
// Self: Unpin
// {
// #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
// async fn send_cmd(&mut self, cmd: crate::device::command::Command) -> Result<usize, Error> {
// let cmd = cmd.with_break();
// let bytes = cmd.as_bytes();
// self.write_all(bytes).await?;
// self.flush().await?;
// Ok(bytes.len())
// }
// }
// }
17 changes: 17 additions & 0 deletions support/device/src/interface/blocking.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
use crate::device::command::Command;
use crate::error::Error;

pub trait Out: In {
// type Error: std::error::Error = crate::error::Error;

// fn send(&self, data: &[u8]) -> Result<usize, Self::Error>;
fn send_cmd(&self, cmd: Command) -> Result<usize, Error>;
}

pub trait In {
// type Error: std::error::Error = crate::error::Error;
}

pub trait Interface: In + Out {}
// impl<T: In<Error = Err> + Out<Error = Err>, Err> Interface for T {}
impl<T: In + Out> Interface for T {}
84 changes: 84 additions & 0 deletions support/device/src/interface/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
use futures::FutureExt;

use crate::error::Error;

pub mod blocking;
pub mod r#async;


pub enum Interface {
Usb(crate::usb::Interface),
Serial(crate::serial::Interface),
}

impl From<crate::usb::Interface> for Interface {
fn from(interface: crate::usb::Interface) -> Self { Self::Usb(interface) }
}

impl From<crate::serial::Interface> for Interface {
fn from(interface: crate::serial::Interface) -> Self { Self::Serial(interface) }
}


impl std::fmt::Display for Interface {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Interface::Usb(interface) => interface.fmt(f),
Interface::Serial(interface) => interface.fmt(f),
}
}
}


impl std::fmt::Debug for Interface {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Usb(_) => f.debug_tuple("Usb").finish(),
Self::Serial(i) => f.debug_tuple("Serial").field(i.info()).finish(),
}
}
}


impl r#async::Out for Interface
where crate::usb::Interface: r#async::Out,
crate::serial::Interface: r#async::Out
{
#[inline(always)]
fn send(&self, data: &[u8]) -> impl futures::Future<Output = Result<usize, Error>> {
match self {
Self::Usb(i) => i.send(data).left_future(),
Self::Serial(i) => i.send(data).right_future(),
}
}

#[inline(always)]
async fn send_cmd(&self, cmd: crate::device::command::Command) -> Result<usize, Error> {
match self {
Interface::Usb(i) => r#async::Out::send_cmd(i, cmd).await,
Interface::Serial(i) => r#async::Out::send_cmd(i, cmd).await,
}
}
}

impl r#async::In for Interface
where crate::usb::Interface: r#async::In,
crate::serial::Interface: r#async::In
{
// type Error = Error;
}


impl blocking::Out for Interface {
#[inline(always)]
fn send_cmd(&self, cmd: crate::device::command::Command) -> Result<usize, Error> {
match self {
Interface::Usb(i) => blocking::Out::send_cmd(i, cmd),
Interface::Serial(i) => blocking::Out::send_cmd(i, cmd),
}
}
}

impl blocking::In for Interface {
// type Error = crate::error::Error;
}
35 changes: 35 additions & 0 deletions support/device/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#![allow(trivial_bounds)]
#![feature(trivial_bounds)]
#![feature(error_generic_member_access)]
#![feature(exit_status_error)]
#![feature(associated_type_defaults)]
#![cfg_attr(feature = "tracing", allow(unused_braces))]

#[macro_use]
#[cfg(feature = "tracing")]
extern crate tracing;

#[macro_use]
#[cfg(not(feature = "tracing"))]
extern crate log;

pub extern crate serialport;

pub mod error;
pub mod serial;
pub mod usb;
pub mod device;
pub mod mount;

pub mod send;
pub mod install;
pub mod run;

pub mod interface;

pub mod retry;


pub const VENDOR_ID: u16 = 0x1331;
pub const PRODUCT_ID_DATA: u16 = 0x5740;
pub const PRODUCT_ID_STORAGE: u16 = 0x5741;
347 changes: 347 additions & 0 deletions support/device/src/mount/linux.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,347 @@
use std::borrow::Cow;
use std::collections::HashMap;
use std::ffi::OsStr;
use std::path::Path;
use std::path::PathBuf;
use std::future::Future;
use std::future::IntoFuture;

use futures::FutureExt;
use udev::Enumerator;

use crate::device::serial::SerialNumber;
use crate::error::Error;
use crate::device::Device;


#[derive(Debug, Clone)]
pub struct Volume {
/// FS mount point.
path: PathBuf,

/// Partition node path, e.g.: `/dev/sda1`.
part_node: PathBuf,

/// Disk node path, e.g.: `/dev/sda`.
disk_node: PathBuf,

/// Device sysfs path.
dev_sysfs: PathBuf,
}

impl Volume {
fn new(path: PathBuf, part: PathBuf, disk: PathBuf, dev_sysfs: PathBuf) -> Self {
Self { path,
part_node: part,
disk_node: disk,
dev_sysfs }
}
}

impl std::fmt::Display for Volume {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.path.display().fmt(f) }
}

impl Volume {
/// This volume's path.
pub fn path(&self) -> Cow<'_, Path> { self.path.as_path().into() }
}


mod unmount {
use futures::TryFutureExt;

use super::*;
use crate::mount::Unmount;
use crate::mount::UnmountAsync;


impl Unmount for Volume {
#[cfg_attr(feature = "tracing", tracing::instrument())]
fn unmount_blocking(&self) -> Result<(), Error> {
use std::process::Command;


let res = eject(self).status()
.map_err(Error::from)
.and_then(|res| res.exit_ok().map_err(Error::from))
.or_else(|err| -> Result<(), Error> {
unmount(self).status()
.map_err(Error::from)
.and_then(|res| res.exit_ok().map_err(Error::from))
.map_err(|err2| Error::chain(err2, [err]))
})
.or_else(move |err| -> Result<(), Error> {
udisksctl_unmount(self).status()
.map_err(Error::from)
.and_then(|res| res.exit_ok().map_err(Error::from))
.map_err(|err2| Error::chain(err2, [err]))
})
.or_else(move |err| -> Result<(), Error> {
udisks_unmount(self).status()
.map_err(Error::from)
.and_then(|res| res.exit_ok().map_err(Error::from))
.map_err(|err2| Error::chain(err2, [err]))
})
.inspect(|_| trace!("unmounted {self}"));

// TODO: use `udisks_power_off` also as fallback for `udisksctl_power_off`:
Command::from(udisksctl_power_off(self)).status()
.map_err(Error::from)
.and_then(|res| res.exit_ok().map_err(Error::from))
.map_err(move |err2| {
if let Some(err) = res.err() {
Error::chain(err2, [err])
} else {
err2
}
})
}
}

#[cfg(feature = "tokio")]
impl UnmountAsync for Volume {
#[cfg_attr(feature = "tracing", tracing::instrument())]
async fn unmount(&self) -> Result<(), Error> {
use tokio::process::Command;
use futures_lite::future::ready;


Command::from(eject(self)).status()
.map_err(Error::from)
.and_then(|res| ready(res.exit_ok().map_err(Error::from)))
.or_else(|err| {
Command::from(unmount(self)).status()
.map_err(|err2| Error::chain(err2, [err]))
.and_then(|res| {
ready(res.exit_ok().map_err(Error::from))
})
})
.or_else(|err| {
Command::from(udisksctl_unmount(self)).status()
.map_err(|err2| {
Error::chain(err2, [err])
})
.and_then(|res| {
ready(
res.exit_ok()
.map_err(Error::from),
)
})
})
.or_else(|err| {
Command::from(udisks_unmount(self)).status()
.map_err(|err2| {
Error::chain(err2, [err])
})
.and_then(|res| {
ready(
res.exit_ok()
.map_err(Error::from),
)
})
})
.inspect_ok(|_| trace!("unmounted {self}"))
.then(|res| {
// TODO: use `udisks_power_off` also as fallback for `udisksctl_power_off`:
Command::from(udisksctl_power_off(self)).status()
.map_err(Error::from)
.and_then(|res| {
ready(
res.exit_ok()
.map_err(Error::from),
)
})
.map_err(|err2| {
if let Some(err) = res.err() {
Error::chain(err2, [err])
} else {
err2
}
})
})
.await
}
}


fn eject(vol: &Volume) -> std::process::Command {
let mut cmd = std::process::Command::new("eject");
cmd.arg(vol.path().as_ref());
cmd
}

fn unmount(vol: &Volume) -> std::process::Command {
let mut cmd = std::process::Command::new("umount");
cmd.arg(vol.path().as_ref());
cmd
}

fn udisksctl_unmount(vol: &Volume) -> std::process::Command {
let mut cmd = std::process::Command::new("udisksctl");
cmd.args(["unmount", "--no-user-interaction", "-b"]);
cmd.arg(&vol.part_node);
cmd
}

fn udisksctl_power_off(vol: &Volume) -> std::process::Command {
let mut cmd = std::process::Command::new("udisksctl");
cmd.args(["power-off", "--no-user-interaction", "-b"]);
cmd.arg(&vol.disk_node);
cmd
}

fn udisks_unmount(vol: &Volume) -> std::process::Command {
let mut cmd = std::process::Command::new("udisks");
cmd.arg("--unmount");
cmd.arg(&vol.part_node);
cmd
}

fn udisks_power_off(vol: &Volume) -> std::process::Command {
let mut cmd = std::process::Command::new("udisks");
cmd.arg("--detach");
cmd.arg(&vol.disk_node);
cmd
}

// NOTE: mb. try to use `udisks`, that's existing in Ubuntu.
// udisksctl unmount -b /dev/sdc1 && udisksctl power-off -b /dev/sdc
// udisks --unmount /dev/sdb1 && udisks --detach /dev/sdb
}


#[cfg_attr(feature = "tracing", tracing::instrument(fields(dev = dev.as_ref().serial_number())))]
pub async fn volume_for<Info>(dev: Info) -> Result<Volume, Error>
where Info: AsRef<nusb::DeviceInfo> {
let sysfs = dev.as_ref().sysfs_path();
let mut enumerator = enumerator()?;
enumerator.add_syspath(sysfs)?;

if let Some(sn) = dev.as_ref().serial_number() {
enumerator.match_property("ID_SERIAL_SHORT", sn)?;
}

let mounts = lfs_core::read_mountinfo()?;
enumerator.scan_devices()?
.filter_map(|udev| {
udev.devtype()
.filter(|ty| *ty == OsStr::new("partition"))
.is_some()
.then(move || udev.devnode().map(Path::to_path_buf).map(|node| (udev, node)))
})
.flatten()
.find_map(|(udev, node)| {
mounts.iter()
.find(|inf| Path::new(inf.fs.as_str()) == node.as_path())
.map(|inf| (udev, node, inf))
})
.and_then(|(udev, node, minf)| {
let disk = udev.parent()
.filter(is_disk)
.or_else(|| udev.parent().map(|d| d.parent().filter(is_disk)).flatten())
.and_then(|dev| dev.devnode().map(ToOwned::to_owned));
let sysfs = PathBuf::from(sysfs);
disk.map(move |disk| Volume::new(minf.mount_point.clone(), node, disk, sysfs))
})
.ok_or_else(|| Error::not_found())
}


#[cfg_attr(feature = "tracing", tracing::instrument(skip(devs)))]
pub async fn volumes_for_map<I>(devs: I) -> Result<HashMap<Device, Option<Volume>>, Error>
where I: IntoIterator<Item = Device> {
let mounts = lfs_core::read_mountinfo()?;

if mounts.is_empty() {
return Ok(devs.into_iter().map(|dev| (dev, None)).collect());
}

let mut enumerator = enumerator()?;

let udevs: Vec<_> = enumerator.scan_devices()?
.filter(is_partition)
.filter_map(|dev| {
if let Some(sn) = dev.property_value("ID_SERIAL_SHORT") {
let sn = sn.to_string_lossy().to_string();
Some((dev, sn))
} else {
if let Some(sn) = dev.property_value("ID_SERIAL") {
let sn: Result<SerialNumber, _> =
sn.to_string_lossy().as_ref().try_into();
sn.ok().map(|sn| (dev, sn.to_string()))
} else {
None
}
}
})
.collect();

if udevs.is_empty() {
return Ok(devs.into_iter().map(|dev| (dev, None)).collect());
}

let mut devs = devs.into_iter().filter_map(|dev| {
if let Some(sn) = dev.info().serial_number().map(ToOwned::to_owned) {
Some((dev, sn))
} else {
None
}
});

let result =
devs.map(|(dev, ref sna)| {
let node =
udevs.iter()
.find_map(|(inf, snb)| {
(sna == snb).then(|| inf.devnode())
.flatten()
.map(ToOwned::to_owned)
.map(|dn| (inf, dn))
})
.and_then(|(udev, node)| {
mounts.iter()
.find(|inf| Path::new(inf.fs.as_str()) == node)
.and_then(|inf| {
let disk = udev.parent()
.filter(is_disk)
.or_else(|| udev.parent().map(|d| d.parent().filter(is_disk)).flatten())
.and_then(|dev| dev.devnode().map(ToOwned::to_owned));

let sysfs = dev.info().sysfs_path().to_owned();
disk.map(move |disk| Volume::new(inf.mount_point.clone(), node, disk, sysfs))
})
});
(dev, node)
})
.collect();
Ok(result)
}


// TODO: this is needed too:
// pub fn volumes_for<'i, I: 'i>(
// devs: I)
// -> Result<impl Iterator<Item = (impl Future<Output = Result<PathBuf, Error>>, &'i Device)>, Error>
// where I: IntoIterator<Item = &'i Device> {
// //
// Ok(vec![(futures::future::lazy(|_| todo!()).into_future(), &todo!())].into_iter())
// }


fn enumerator() -> Result<Enumerator, Error> {
let mut enumerator = udev::Enumerator::new()?;
// filter only PD devices:
enumerator.match_property("ID_VENDOR", "Panic")?;
enumerator.match_property("ID_MODEL", "Playdate")?;
Ok(enumerator)
}


fn is_partition(dev: &udev::Device) -> bool {
dev.devtype()
.filter(|ty| *ty == OsStr::new("partition"))
.is_some()
}

fn is_disk(dev: &udev::Device) -> bool { dev.devtype().filter(|ty| *ty == OsStr::new("disk")).is_some() }
271 changes: 271 additions & 0 deletions support/device/src/mount/mac.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
use std::borrow::Cow;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use futures::Future;
use futures::FutureExt;
use serde::Deserialize;
use crate::device::Device;
use crate::error::Error;


pub const VENDOR_ID_ENC: &str = const_hex::const_encode::<2, true>(&crate::VENDOR_ID.to_be_bytes()).as_str();


#[derive(Debug, Clone)]
pub struct Volume {
path: PathBuf,
}

impl From<PathBuf> for Volume {
fn from(path: PathBuf) -> Self { Self { path } }
}

impl std::fmt::Display for Volume {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.path.display().fmt(f) }
}

impl Volume {
/// This volume's path.
pub fn path(&self) -> Cow<'_, Path> { self.path.as_path().into() }
}


mod unmount {
use super::*;
use crate::mount::Unmount;
use crate::mount::UnmountAsync;


impl Unmount for Volume {
#[cfg_attr(feature = "tracing", tracing::instrument())]
fn unmount_blocking(&self) -> Result<(), Error> {
cmd(self).status()?
.exit_ok()
.map(|_| trace!("unmounted {self}"))
.map_err(Into::into)
}
}

#[cfg(feature = "tokio")]
impl UnmountAsync for Volume {
#[cfg_attr(feature = "tracing", tracing::instrument())]
async fn unmount(&self) -> Result<(), Error> {
tokio::process::Command::from(cmd(self)).status()
.await?
.exit_ok()
.map(|_| trace!("unmounted {self}"))
.map_err(Into::into)
}
}

fn cmd(vol: &Volume) -> std::process::Command {
let mut cmd = std::process::Command::new("diskutil");
cmd.arg("eject");
cmd.arg(vol.path().as_ref());
cmd
}
}


#[derive(Debug)]
pub struct SpusbInfo<Fut>
where Fut: Future<Output = Result<PathBuf, Error>> {
pub name: String,
pub serial: String,
pub volume: Fut,
}


#[cfg_attr(feature = "tracing", tracing::instrument(fields(dev = dev.as_ref().serial_number())))]
pub async fn volume_for<Info>(dev: Info) -> Result<Volume, Error>
where Info: AsRef<nusb::DeviceInfo> {
if let Some(sn) = dev.as_ref().serial_number() {
let res = spusb(move |ref info| info.serial_num == sn).map(|mut iter| iter.next().map(|info| info.volume));
match res {
Ok(None) => Err(Error::not_found()),
Ok(Some(fut)) => Ok(fut),
Err(err) => Err(err),
}
} else {
Err(Error::not_found())
}?.await
.map(Volume::from)
}

#[cfg_attr(feature = "tracing", tracing::instrument(skip(devs)))]
pub async fn volumes_for_map<I>(devs: I) -> Result<HashMap<Device, Option<Volume>>, Error>
where I: IntoIterator<Item = Device> {
let mut devs = devs.into_iter()
.filter_map(|dev| {
if let Some(sn) = dev.info().serial_number().map(ToOwned::to_owned) {
Some((dev, sn))
} else {
None
}
})
.collect::<Vec<_>>();

let mut results = HashMap::with_capacity(devs.len());

for info in spusb(|_| true)? {
let i = devs.iter()
.enumerate()
.find(|(_, (_, sn))| &info.serial == sn)
.map(|(i, _)| i);

if let Some(i) = i {
match info.volume.await {
Ok(vol) => {
let (dev, _) = devs.remove(i);
results.insert(dev, Some(Volume { path: vol }));
},
Err(err) => error!("{err}"),
}
}
}

results.extend(devs.into_iter().map(|(dev, _)| (dev, None)));

Ok(results)
}

#[cfg_attr(feature = "tracing", tracing::instrument(skip(devs)))]
pub fn volumes_for<'i, I: 'i>(
devs: I)
-> Result<impl Iterator<Item = (impl Future<Output = Result<PathBuf, Error>>, &'i Device)>, Error>
where I: IntoIterator<Item = &'i Device> {
let devs = devs.into_iter()
.filter_map(|dev| dev.info().serial_number().map(|sn| (dev, sn)))
.collect::<Vec<_>>();

spusb(|_| true).map(move |iter| {
iter.filter_map(move |info| {
devs.iter()
.find(|(_, sn)| info.serial == *sn)
.map(|(dev, _)| (info.volume, *dev))
})
})
}


/// Call `system_profiler -json SPUSBDataType`
#[cfg_attr(feature = "tracing", tracing::instrument(skip(filter)))]
fn spusb<F>(filter: F)
-> Result<impl Iterator<Item = SpusbInfo<impl Future<Output = Result<PathBuf, Error>>>>, Error>
where F: FnMut(&DeviceInfo) -> bool {
use std::process::Command;

let output = Command::new("system_profiler").args(["-json", "SPUSBDataType"])
.output()?;
output.status.exit_ok()?;

let data: SystemProfilerResponse = serde_json::from_reader(&output.stdout[..])?;

let result = data.data
.into_iter()
.filter_map(|c| c.items)
.flatten()
.filter(|item| item.vendor_id == VENDOR_ID_ENC)
.filter(filter)
.filter_map(|item| {
let DeviceInfo { name,
serial_num: serial,
media,
.. } = item;
let volume = media.map(|media| {
media.into_iter()
.flat_map(|root| root.volumes.into_iter())
.filter_map(|par| {
if let Some(path) = par.mount_point {
trace!("found mount-point: {}", path.display());
Some(futures_lite::future::ready(Ok(path)).left_future())
} else {
let path = Path::new("/Volumes").join(&par.name);
if path.exists() {
trace!("existing, by name: {}", path.display());
Some(futures_lite::future::ready(Ok(path)).left_future())
} else if par.volume_uuid.is_some() {
trace!("not mounted yet, create resolver fut");
Some(mount_point_for_partition(par).right_future())
} else {
None
}
}
})
.next()
})
.flatten();
volume.map(|volume| SpusbInfo { name, serial, volume })
});
Ok(result)
}


/// Calls `diskutil info -plist {partition.volume_uuid}`
#[cfg_attr(feature = "tracing", tracing::instrument(skip(par), fields(par.name = par.name.as_str())))]
async fn mount_point_for_partition(par: MediaPartitionInfo) -> Result<PathBuf, Error> {
use std::process::Command;

if let Some(volume_uuid) = par.volume_uuid.as_deref() {
let output = Command::new("diskutil").args(["info", "-plist"])
.arg(volume_uuid)
.output()?;
output.status.exit_ok()?;

let info: DiskUtilResponse = plist::from_bytes(output.stdout.as_slice())?;
info.mount_point
.ok_or(Error::MountNotFound(format!("{} {}", &par.name, &par.bsd_name)))
.map(PathBuf::from)
} else {
Err(Error::MountNotFound(format!("{} {}", &par.name, &par.bsd_name)))
}
}


#[derive(Deserialize, Debug)]
struct DiskUtilResponse {
#[serde(rename = "MountPoint")]
mount_point: Option<String>,
}


#[derive(Deserialize, Debug)]
struct SystemProfilerResponse {
#[serde(rename = "SPUSBDataType")]
data: Vec<ControllerInfo>,
}


#[derive(Deserialize, Debug)]
struct ControllerInfo {
#[serde(rename = "_items")]
items: Option<Vec<DeviceInfo>>,
}

#[derive(Deserialize, Debug)]
#[allow(dead_code)]
pub struct DeviceInfo {
#[serde(rename = "_name")]
pub name: String,
pub serial_num: String,
pub vendor_id: String,

#[serde(rename = "Media")]
pub media: Option<Vec<DeviceMediaInfo>>,
}


#[derive(Deserialize, Debug)]
pub struct DeviceMediaInfo {
volumes: Vec<MediaPartitionInfo>,
}

#[derive(Deserialize, Debug)]
pub struct MediaPartitionInfo {
#[serde(rename = "_name")]
name: String,
bsd_name: String,
volume_uuid: Option<String>,
mount_point: Option<PathBuf>,
}
384 changes: 384 additions & 0 deletions support/device/src/mount/methods.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,384 @@
use std::future::Future;
use std::time::Duration;

use futures::stream::FuturesUnordered as Unordered;
use futures::{FutureExt, Stream, StreamExt, TryFutureExt};

use crate::device::query::Query;
use crate::device::query::Value as QueryValue;
use crate::device::serial::SerialNumber as Sn;
use crate::device::{wait_mode_storage, wait_mode_data, Device};
use crate::error::Error;
use crate::interface::r#async::Out;
use crate::mount::{MountAsync, MountHandle};
use crate::mount::MountedDevice;
use crate::mount::volume::volumes_for_map;
use crate::retry::{DefaultIterTime, Retries, IterTime};
use crate::usb::discover::devices_storage;
use crate::usb;
use crate::serial::{self, dev_with_port};
use crate::interface;


type Result<T = (), E = Error> = std::result::Result<T, E>;


/// Recommended total time for retries is 30 seconds or more.
///
/// ```ignore
/// let retry = Retries::new(Duration::from_secs(1), Duration::from_secs(60));
/// mount::wait_fs_available(drive, retry).await?;
/// ```
#[cfg_attr(feature = "tracing", tracing::instrument(fields(dev = mount.device.to_string(),
mount = mount.handle.volume().path().as_ref().display().to_string(),
)))]
pub async fn wait_fs_available<T>(mount: &MountedDevice, retry: Retries<T>) -> Result
where T: Clone + std::fmt::Debug + IterTime {
let total = &retry.total;
let iter_ms = retry.iters.interval(total);
let retries_num = total.as_millis() / iter_ms.as_millis();
debug!("retries: {retries_num} * {iter_ms:?} ≈ {total:?}.");

let mut counter = retries_num;
let mut interval = tokio::time::interval(iter_ms);

let check = || {
mount.handle
.path()
.try_exists()
.inspect_err(|err| debug!("{err}"))
.ok()
.unwrap_or_default()
.then(|| {
let path = mount.handle.path();
match std::fs::read_dir(path).inspect_err(|err| debug!("{err}")) {
// then find first dir entry:
Ok(entries) => entries.into_iter().flatten().next().is_some(),
_ => false,
}
})
.unwrap_or_default()
};

if check() {
trace!("filesystem available at {}", mount.handle.path().display());
return Ok(());
}

while {
counter -= 1;
counter
} != 0
{
interval.tick().await;

if check() {
return Ok(());
} else {
trace!(
"{dev}: waiting filesystem availability, try: {i}/{retries_num}",
dev = mount.device,
i = retries_num - counter,
);
}
}

Err(Error::timeout(format!(
"{dev}: filesystem not available at {path} after {retries_num} retries",
dev = mount.device,
path = mount.handle.path().display(),
)))
}


#[cfg_attr(feature = "tracing", tracing::instrument())]
pub async fn mount(query: Query) -> Result<impl Stream<Item = Result<MountedDevice>>> {
match query.value {
Some(QueryValue::Path(port)) => {
let fut = mount_by_port_name(port.display().to_string()).await?
.left_stream();
Ok(fut)
},
Some(QueryValue::Com(port)) => {
let fut = mount_by_port_name(format!("COM{port}")).await?.left_stream();
Ok(fut)
},
Some(QueryValue::Serial(sn)) => Ok(mount_by_sn_mb(Some(sn)).await?.right_stream()),
_ => Ok(mount_by_sn_mb(None).await?.right_stream()),
}
}


/// Switch between stream methods `mount` and `mount then wait_fs_available`,
/// depending on `wait` parameter.
#[cfg_attr(feature = "tracing", tracing::instrument())]
pub async fn mount_and(query: Query, wait: bool) -> Result<impl Stream<Item = Result<MountedDevice>>> {
let fut =
mount(query).await?.flat_map(move |res| {
async move {
match res {
Ok(drive) => {
if wait {
let retry =
Retries::new(Duration::from_millis(500), Duration::from_secs(60));
wait_fs_available(&drive, retry).await?
}
Ok(drive)
},
Err(err) => Err(err),
}
}.into_stream()
});
Ok(fut)
}


#[cfg_attr(feature = "tracing", tracing::instrument())]
pub async fn mount_by_sn_mb(sn: Option<Sn>) -> Result<Unordered<impl Future<Output = Result<MountedDevice>>>> {
let devices = usb::discover::devices_with(sn)?;
let mounting = devices.map(mount_dev);

let futures = Unordered::new();
for dev in mounting {
futures.push(dev?);
}

if futures.is_empty() {
Err(Error::not_found())
} else {
Ok(futures)
}
}


#[cfg_attr(feature = "tracing", tracing::instrument(fields(port = port.as_ref())))]
pub async fn mount_by_port_name<S: AsRef<str>>(
port: S)
-> Result<Unordered<impl Future<Output = Result<MountedDevice>>>> {
let port = port.as_ref();
let existing = serial::discover::ports().map(|ports| {
ports.into_iter()
.find(|p| p.port_name == port)
.map(serial::Interface::new)
});

let futures = Unordered::new();

let err_not_found = || futures_lite::future::ready(Err(Error::not_found()));

match existing {
Ok(Some(port)) => {
if let serialport::SerialPortType::UsbPort(serialport::UsbPortInfo { serial_number: Some(ref sn),
.. }) = port.info().port_type
{
let dev = Sn::try_from(sn.as_str()).map_err(Error::from)
.and_then(|sn| usb::discover::device(&sn));
match dev {
Ok(mut dev) => {
dev.set_interface(interface::Interface::Serial(port));
futures.push(mount_dev(dev)?.left_future());
},
Err(err) => {
let name = port.info().port_name.as_str();
error!("Unable to map specified port {name} to device: {err}");
port.mount().await?;
futures.push(err_not_found().right_future());
},
}
}
},
Ok(None) => {
match dev_with_port(port).await {
Ok(dev) => futures.push(mount_dev(dev)?.left_future()),
Err(err) => {
let name = port;
error!("Unable to map specified port {name} to device: {err}");
let port = serial::open(name)?;
let interface = serial::Interface::new_with(port, Some(name.to_string()));
interface.send_cmd(crate::device::command::Command::Datadisk)
.await?;
futures.push(err_not_found().right_future());
},
}
},
Err(err) => {
error!("{err}");
match dev_with_port(port).await {
Ok(dev) => futures.push(mount_dev(dev)?.left_future()),
Err(err) => {
let name = port;
error!("Unable to map specified port {name} to device: {err}");
let port = serial::open(name)?;
let interface = serial::Interface::new_with(port, Some(name.to_string()));
interface.send_cmd(crate::device::command::Command::Datadisk)
.await?;
futures.push(err_not_found().right_future());
},
}
},
}

if futures.is_empty() {
Err(Error::not_found())
} else {
Ok(futures)
}
}


#[cfg_attr(feature = "tracing", tracing::instrument(fields(dev = dev.info().serial_number())))]
fn mount_dev(mut dev: Device) -> Result<impl Future<Output = Result<MountedDevice>>> {
let retry = Retries::<DefaultIterTime>::default();
let mut retry_wait_mount_point = retry.clone();
retry_wait_mount_point.total += Duration::from_secs(40);

trace!("mounting {dev}");
let fut = match dev.mode_cached() {
usb::mode::Mode::Data => {
trace!("create sending fut");
async move {
dev.open()?;
dev.interface()?
.send_cmd(crate::device::command::Command::Datadisk)
.await?;
dev.close();
Ok(dev)
}.and_then(|dev| wait_mode_storage(dev, retry))
.left_future()
},
usb::mode::Mode::Storage => futures_lite::future::ready(Ok(dev)).right_future(),
mode => return Err(Error::WrongState(mode)),
};
Ok(fut.and_then(|dev| wait_mount_point(dev, retry_wait_mount_point)))
}


#[cfg_attr(feature = "tracing", tracing::instrument(fields(dev = dev.info().serial_number())))]
async fn wait_mount_point<T>(dev: Device, retry: Retries<T>) -> Result<MountedDevice>
where T: Clone + std::fmt::Debug + IterTime {
let total = &retry.total;
let iter_ms = retry.iters.interval(total);
let retries_num = total.as_millis() / iter_ms.as_millis();
debug!("retries: {retries_num} * {iter_ms:?} ≈ {total:?}.");

let mut counter = retries_num;
let mut interval = tokio::time::interval(iter_ms);

let sn = dev.info()
.serial_number()
.ok_or_else(|| Error::DeviceSerial { source: "unknown".into() })?
.to_owned();

while {
counter -= 1;
counter
} != 0
{
interval.tick().await;

let mode = dev.mode_cached();
trace!(
"waiting mount point availability: {sn}, current: {mode}, try: {}/{retries_num}",
retries_num - counter
);

let vol = crate::mount::volume::volume_for(&dev).await
.map_err(|err| debug!("ERROR: {err}"))
.ok();
if let Some(vol) = vol {
debug!("{sn} mounted, volume found: '{vol}'");
let handle = MountHandle::new(vol, false);
let mounted = MountedDevice::new(dev, handle);
return Ok(mounted);
} else {
debug!("mount point still not found, waiting...")
}
}

Err(Error::usb_timeout(dev))
}


#[cfg_attr(feature = "tracing", tracing::instrument())]
pub async fn unmount(query: Query) -> Result<Unordered<impl Future<Output = (Device, Result)>>> {
match query.value {
Some(QueryValue::Path(path)) => {
// TODO: Check query is path and this is mounted volume.
// check is `path` is a a path of existing __volume__,
// try find device behind the volume,
// unmount the volume anyway
todo!("unmount dev by vol path: {}", path.display())
},
Some(QueryValue::Com(_)) => todo!("ERROR: not supported (impossible)"),
Some(QueryValue::Serial(sn)) => unmount_mb_sn(Some(sn)),
_ => unmount_mb_sn(None),
}.await
}

/// Unmount device(s), then wait for state change to [`Data`][usb::mode::Mode::Data].
#[cfg_attr(feature = "tracing", tracing::instrument())]
pub async fn unmount_and_wait<T>(query: Query, retry: Retries<T>) -> Result<impl Stream<Item = Result<Device>>>
where T: Clone + std::fmt::Debug + IterTime {
let stream = Unordered::new();
unmount(query).await?
.for_each_concurrent(4, |(dev, res)| {
if let Some(err) = res.err() {
error!("{dev}: {err}")
}
stream.push(wait_mode_data(dev, retry.clone()));
futures_lite::future::ready(())
})
.await;

trace!("Waiting state change for {} devices.", stream.len());
Ok(stream)
}

/// Switch between stream methods `unmount` and `unmount_and_wait`,
/// depending on `wait` parameter.
#[cfg_attr(feature = "tracing", tracing::instrument())]
pub async fn unmount_and(query: Query, wait: bool) -> Result<impl Stream<Item = Result<Device>>> {
let results = if wait {
let retry = Retries::<DefaultIterTime>::default();
unmount_and_wait(query, retry).await?.left_stream()
} else {
unmount(query).await?
.map(|(dev, res)| res.map(|_| dev))
.right_stream()
};

Ok(results)
}


#[cfg_attr(feature = "tracing", tracing::instrument())]
pub async fn unmount_mb_sn(sn: Option<Sn>) -> Result<Unordered<impl Future<Output = (Device, Result)>>> {
let devs = devices_storage()?.filter(move |dev| {
sn.as_ref()
.filter(|qsn| dev.info().serial_number().filter(|ref s| qsn.eq(s)).is_some())
.is_some() ||
sn.is_none()
})
.inspect(|dev| trace!("Unmounting {dev}"));

let unmounting = volumes_for_map(devs).await?
.into_iter()
.filter_map(|(dev, vol)| vol.map(|vol| (dev, vol)))
.inspect(|(dev, vol)| trace!("Unmounting {dev}: {vol}"))
.map(|(dev, vol)| {
let h = MountHandle::new(vol, false);
MountedDevice::new(dev, h)
})
.map(move |mut dev| {
use crate::mount::UnmountAsync;
async move {
dev.device.close();
let res = dev.unmount().await;
(dev.device, res)
}
})
.collect::<Unordered<_>>();

trace!("Unmounting {} devices.", unmounting.len());
Ok(unmounting)
}
166 changes: 166 additions & 0 deletions support/device/src/mount/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
use std::borrow::Cow;
use std::ops::Deref;
use std::ops::DerefMut;
use std::path::Path;

use crate::device::Device;
use crate::error::Error;


#[cfg(target_os = "macos")]
#[path = "mac.rs"]
pub mod volume;
#[cfg(target_os = "windows")]
#[path = "win.rs"]
pub mod volume;
#[cfg(target_os = "linux")]
#[path = "linux.rs"]
pub mod volume;

mod methods;
pub use methods::*;


// TODO: If unmount fails, do warn!("Please press 'A' on the Playdate to exit Data Disk mode.")


// TODO: MountError for this module


pub trait Volume {
/// This volume's path.
fn path(&self) -> Cow<'_, Path>;
}

pub trait Unmount {
/// Unmount this volume. Blocking.
fn unmount_blocking(&self) -> Result<(), Error>;
}

pub trait UnmountAsync {
/// Unmount this volume.
fn unmount(&self) -> impl std::future::Future<Output = Result<(), Error>>;
}

pub trait Mount {
/// Mount this volume. Blocking.
fn mount_blocking(&self) -> Result<(), Error>;
}

pub trait MountAsync {
fn mount(&self) -> impl std::future::Future<Output = Result<(), Error>>;
}


impl Mount for Device {
fn mount_blocking(&self) -> Result<(), Error> {
use crate::interface::blocking::Out;
use crate::device::command::Command;

self.interface()?.send_cmd(Command::Datadisk)?;
Ok(())
}
}

impl MountAsync for Device {
async fn mount(&self) -> Result<(), Error> { self.interface()?.mount().await }
}


impl<T> MountAsync for T where T: crate::interface::r#async::Out {
async fn mount(&self) -> Result<(), Error> {
self.send_cmd(crate::device::command::Command::Datadisk).await?;
Ok(())
}
}

impl<T> Mount for T where T: crate::interface::blocking::Out {
fn mount_blocking(&self) -> Result<(), Error> {
self.send_cmd(crate::device::command::Command::Datadisk)?;
Ok(())
}
}


impl<T> UnmountAsync for T where T: crate::interface::r#async::Out {
async fn unmount(&self) -> Result<(), Error> {
self.send_cmd(crate::device::command::Command::Datadisk).await?;
Ok(())
}
}

impl<T> Unmount for T where T: crate::interface::blocking::Out {
fn unmount_blocking(&self) -> Result<(), Error> {
self.send_cmd(crate::device::command::Command::Datadisk)?;
Ok(())
}
}


pub struct MountedDevice {
pub device: Device,
pub handle: MountHandle,
}

impl Unmount for MountedDevice {
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(dev = self.info().serial_number(), mount = self.handle.volume().path().as_ref().display().to_string())))]
fn unmount_blocking(&self) -> Result<(), Error> {
<volume::Volume as Unmount>::unmount_blocking(&self.handle.volume)
}
}

impl UnmountAsync for MountedDevice where volume::Volume: UnmountAsync {
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(dev = self.info().serial_number(), mount = self.handle.volume().path().as_ref().display().to_string())))]
fn unmount(&self) -> impl std::future::Future<Output = Result<(), Error>> {
<volume::Volume as UnmountAsync>::unmount(&self.handle.volume)
}
}

impl MountedDevice {
pub fn new(device: Device, handle: MountHandle) -> Self { Self { device, handle } }
}

impl Deref for MountedDevice {
type Target = Device;
fn deref(&self) -> &Self::Target { &self.device }
}

impl DerefMut for MountedDevice {
fn deref_mut(&mut self) -> &mut Self::Target { &mut self.device }
}


pub struct MountHandle {
volume: volume::Volume,
pub unmount_on_drop: bool,
}

impl MountHandle {
pub fn new(volume: volume::Volume, unmount_on_drop: bool) -> Self {
Self { volume,
unmount_on_drop }
}

pub fn path(&self) -> Cow<'_, Path> { self.volume.path() }
pub fn volume(&self) -> &volume::Volume { &self.volume }

pub fn unmount(mut self) {
self.unmount_on_drop = true;
drop(self)
}
}

impl Drop for MountHandle {
fn drop(&mut self) {
if self.unmount_on_drop {
trace!("Unmounting {} by drop", self.volume);
let _ = self.volume
.unmount_blocking()
.map_err(|err| {
error!("{err}");
info!("Please press 'A' on the Playdate to exit Data Disk mode.");
})
.ok();
}
}
}
578 changes: 578 additions & 0 deletions support/device/src/mount/win.rs

Large diffs are not rendered by default.

90 changes: 90 additions & 0 deletions support/device/src/retry.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
//! Retry utils
use std::time::Duration;


#[derive(Clone)]
pub struct Retries<Iters: IterTime = DefaultIterTime> {
/// How many iterations to perform before giving up.
pub iters: Iters,
/// Total awaiting time
pub total: Duration,
}

impl<Iters: IterTime> Retries<Iters> {
pub fn new(iters: Iters, total: Duration) -> Self { Self { iters, total } }
}

impl<T> Default for Retries<T> where T: Default + IterTime {
fn default() -> Self {
Self { iters: Default::default(),
total: Duration::from_secs(10) }
}
}

impl<T> std::fmt::Display for Retries<T> where T: std::fmt::Display + IterTime {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "({} => {:?})", self.iters, self.total)
}
}
impl<T> std::fmt::Debug for Retries<T> where T: std::fmt::Debug + IterTime {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "({:?} => {:?})", self.iters, self.total)
}
}


pub trait IterTime {
fn preferred_iter_time(&self) -> Duration;

#[inline(always)]
fn interval(&self, total_wait: &Duration) -> Duration
where for<'t> &'t Self: IterTime {
calc_interval(total_wait, self)
}
}


impl<T: IterTime> IterTime for &'_ T {
#[inline(always)]
fn preferred_iter_time(&self) -> Duration { T::preferred_iter_time(*self) }

#[inline(always)]
fn interval(&self, total_wait: &Duration) -> Duration
where for<'t> &'t Self: IterTime {
T::interval(*self, total_wait)
}
}

pub fn calc_interval<T: IterTime>(wait: &Duration, cfg: T) -> Duration {
let iters = wait.as_millis() / cfg.preferred_iter_time().as_millis() as u128;
Duration::from_millis((wait.as_millis() / iters) as _)
}


#[derive(Clone, Default)]
pub struct DefaultIterTime;
const MIN_ITER_TIME: u64 = 100;

impl IterTime for DefaultIterTime {
fn preferred_iter_time(&self) -> Duration { Duration::from_millis(MIN_ITER_TIME) }
}

impl std::fmt::Display for DefaultIterTime {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}ms", MIN_ITER_TIME) }
}
impl std::fmt::Debug for DefaultIterTime {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
Duration::from_millis(MIN_ITER_TIME).fmt(f)
}
}


impl IterTime for Duration {
fn preferred_iter_time(&self) -> Duration { self.clone() }

fn interval(&self, total_wait: &Duration) -> Duration
where for<'t> &'t Self: IterTime {
calc_interval(total_wait, self)
}
}
80 changes: 80 additions & 0 deletions support/device/src/run.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
use std::borrow::Cow;
use std::path::PathBuf;

use futures::stream::FuturesUnordered;
use futures::{FutureExt, TryStreamExt};
use futures_lite::StreamExt;

use crate::device::query::Query;
use crate::device::wait_mode_data;
use crate::error::Error;
use crate::mount::UnmountAsync;
use crate::{install, device, usb, interface};


type Result<T = (), E = Error> = std::result::Result<T, E>;


#[cfg_attr(feature = "tracing", tracing::instrument)]
pub async fn run(query: Query,
pdx: PathBuf,
no_install: bool,
no_read: bool,
force: bool)
-> Result<Vec<device::Device>> {
use crate::retry::{DefaultIterTime, Retries};
let wait_data = Retries::<DefaultIterTime>::default();


let to_run = if !no_install {
install::mount_and_install(query, &pdx, force).await?
.filter_map(|r| r.map_err(|e| error!("{e}")).ok())
.flat_map(|path| {
async {
let (mount, path) = path.into_parts();
mount.unmount().await?;
wait_mode_data(mount.device, wait_data.clone()).await
.map(|dev| {
(dev, path.into())
})
}.into_stream()
.filter_map(move |r| r.inspect_err(|e| error!("{e}")).ok())
})
.collect::<Vec<(device::Device, Cow<_>)>>()
.await
} else {
usb::discover::devices_data()?.map(|dev| (dev, pdx.to_string_lossy()))
.collect()
};


let mut to_read = Vec::with_capacity(to_run.len());
let readers = FuturesUnordered::new();

for (mut device, path) in to_run {
use interface::r#async::Out;

device.open()?;
{
let interface = device.interface()?;
interface.send_cmd(device::command::Command::Run { path: path.into_owned() })
.await?;
}

if !no_read {
to_read.push(device);
}
}

if !no_read {
for device in to_read.iter_mut() {
readers.push(usb::io::redirect_to_stdout(device));
}
}

readers.inspect_err(|err| error!("{err}"))
.try_for_each_concurrent(8, |_| async { Ok(()) })
.await?;

Ok(to_read)
}
67 changes: 67 additions & 0 deletions support/device/src/send.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
use futures::{FutureExt, Stream, StreamExt, TryFutureExt};
use futures_lite::stream;

use crate::device::command::Command;
use crate::{device, usb, interface};
use crate::error::Error;
use device::query::Query;
use interface::r#async::Out;


type Result<T = (), E = Error> = std::result::Result<T, E>;


/// Fails if can't map specified port to device in case of query is a port name/path.
/// Use [[send_to_interfaces]] instead if device mapping not needed.
#[cfg_attr(feature = "tracing", tracing::instrument())]
pub async fn send_to_devs(query: Query,
cmd: Command,
read: bool)
-> Result<impl Stream<Item = Result<device::Device>>> {
let devices = usb::discover::devices_data_for(query).await?;

if devices.is_empty() {
return Err(Error::not_found());
}

let devices = devices.into_iter().flat_map(|mut dev| {
dev.open().inspect_err(|err| error!("{err}")).ok()?;
Some(dev)
});
let stream = stream::iter(devices).flat_map_unordered(None, move |mut dev| {
let cmd = cmd.clone();
async move {
match dev.interface_mut().inspect_err(|err| error!("{err}")) {
Ok(interface) => {
if read {
interface.send_cmd(cmd).await?;
usb::io::redirect_interface_to_stdout(interface).await?;
} else {
interface.send_cmd(cmd).await?;
}
Ok(())
},
Err(err) => Err(err),
}?;
Ok::<_, Error>(dev)
}.into_stream()
.boxed_local()
});
Ok(stream)
}


#[cfg_attr(feature = "tracing", tracing::instrument())]
pub async fn send_to_interfaces(query: Query,
cmd: Command)
-> Result<impl Stream<Item = Result<interface::Interface>>> {
usb::discover::for_each_data_interface(query, move |interface| {
let cmd = cmd.clone();
async move {
interface.send_cmd(cmd.clone())
.inspect_err(|err| error!("{err}"))
.await?;
Ok::<_, Error>(interface)
}
}).await
}
29 changes: 29 additions & 0 deletions support/device/src/serial/async.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#![cfg(feature = "tokio-serial")]
#![cfg(feature = "tokio")]

use std::ops::DerefMut;

use tokio::io::AsyncWriteExt;

use crate::error::Error;
use super::Interface;


impl crate::interface::r#async::Out for Interface {
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
async fn send(&self, data: &[u8]) -> Result<usize, Error> {
trace!("writing {} bytes to {}", data.len(), self.info.port_name);
if let Some(ref port) = self.port {
let mut port = port.try_borrow_mut()?;
let port = port.deref_mut();
port.write_all(data).await?;
port.flush().await?;
Ok(data.len())
} else {
Err(Error::not_ready())
}
}
}


impl crate::interface::r#async::In for Interface {}
23 changes: 23 additions & 0 deletions support/device/src/serial/blocking.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
use std::io::prelude::*;

use crate::error::Error;
use super::Interface;


impl crate::interface::blocking::Out for Interface {
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
fn send_cmd(&self, cmd: crate::device::command::Command) -> Result<usize, Error> {
trace!("sending `{cmd}` to {}", self.info.port_name);
if let Some(ref port) = self.port {
let s = cmd.with_break();
let mut port = port.try_borrow_mut()?;
port.write_all(s.as_bytes())?;
port.flush()?;
Ok(s.as_bytes().len())
} else {
Err(Error::not_ready())
}
}
}

impl crate::interface::blocking::In for Interface {}
115 changes: 115 additions & 0 deletions support/device/src/serial/discover.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
use std::borrow::Cow;
use std::fmt::Debug;

use serialport::{SerialPortInfo, SerialPortType, available_ports};

use crate::device::Device;
use crate::error::Error;
use crate::{VENDOR_ID, PRODUCT_ID_DATA};


/// Enumerate all serial ports on the system for Playdate devices.
#[cfg_attr(feature = "tracing", tracing::instrument())]
pub fn ports() -> Result<impl Iterator<Item = SerialPortInfo>, Error> {
let iter = available_ports()?.into_iter().filter(|port| {
match port.port_type {
SerialPortType::UsbPort(ref info) => {
info.vid == VENDOR_ID && info.pid == PRODUCT_ID_DATA
},
_ => false,
}
});
Ok(iter)
}


/// Search exact one serial port for device with same serial number.
#[cfg_attr(feature = "tracing", tracing::instrument())]
pub fn port<SN>(sn: &SN) -> Result<SerialPortInfo, Error>
where SN: PartialEq<str> + Debug {
let port = ports()?.find(move |port| {
match port.port_type {
SerialPortType::UsbPort(ref info) => {
info.serial_number.as_ref().filter(|s| sn.eq(s)).is_some()
},
_ => false,
}
});
// TODO: error: serial not found for sn
port.ok_or_else(|| Error::not_found())
}


/// Search serial ports for device with same serial number,
/// or __any__ Playdate- serial port if `sn` is `None`.
#[cfg_attr(feature = "tracing", tracing::instrument())]
pub fn ports_with<SN>(sn: Option<SN>) -> Result<impl Iterator<Item = SerialPortInfo>, Error>
where SN: PartialEq<str> + Debug {
Ok(ports()?.filter(move |port| sn.as_ref().map(|sn| port_sn_matches(port, sn)).unwrap_or(true)))
}


/// Search serial ports for device with same serial number,
/// or __any__ Playdate- serial port if `sn` is `None`.
///
/// In case of just one device and just one port found, serial number will not be used for matching, so it returns.
#[cfg_attr(feature = "tracing", tracing::instrument())]
pub fn ports_with_or_single<SN>(sn: Option<SN>) -> Result<impl IntoIterator<Item = SerialPortInfo>, Error>
where SN: PartialEq<str> + Debug {
let ports: Vec<_> = ports()?.collect();
let devs: Vec<_> = crate::usb::discover::devices_data()?.collect();

if ports.len() == 1 && devs.len() == 1 {
trace!("Auto-match single found dev with port without sn match.");
let psn = match &ports[0].port_type {
SerialPortType::UsbPort(usb) => usb.serial_number.as_deref(),
SerialPortType::PciPort => None,
SerialPortType::BluetoothPort => None,
SerialPortType::Unknown => None,
};
let name = &ports[0].port_name;
trace!("Found serial port: {name}, sn: {psn:?} for dev: {sn:?}",);
Ok(ports)
} else {
let ports = ports.into_iter()
.filter(move |port| sn.as_ref().map(|sn| port_sn_matches(port, sn)).unwrap_or(true));
Ok(ports.collect())
}
}


fn port_sn_matches<SN>(port: &SerialPortInfo, sn: &SN) -> bool
where SN: PartialEq<str> + Debug {
match port.port_type {
SerialPortType::UsbPort(ref info) => {
trace!("found port: {}, dev sn: {:?}", port.port_name, info.serial_number);
info.serial_number
.as_deref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.filter(|s| {
let res = sn.eq(s);
trace!("sn is ≈ {res}");
res
})
.is_some()
},
_ => false,
}
}


#[cfg_attr(feature = "tracing", tracing::instrument(skip(dev)))]
/// Search serial ports for `device`` with same serial number.
#[cfg(not(target_os = "windows"))]
pub fn ports_for(dev: &Device) -> Result<impl Iterator<Item = SerialPortInfo> + '_, Error> {
ports_with(dev.info().serial_number().map(Cow::Borrowed))
}
#[cfg(target_os = "windows")]
///
/// _On Windows in some strange cases of serial number of the device that behind founded COM port
/// can't be determined of we get just part of it, so we need to use another method to match devices
/// in case of there is just one device and port._
pub fn ports_for(dev: &Device) -> Result<impl Iterator<Item = SerialPortInfo> + '_, Error> {
ports_with_or_single(dev.info().serial_number().map(Cow::Borrowed)).map(|v| v.into_iter())
}
108 changes: 108 additions & 0 deletions support/device/src/serial/methods.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
use std::borrow::Cow;

use crate::device::serial::SerialNumber;
use crate::device::Device;
use crate::error::Error;
use crate::usb;


type Result<T = (), E = Error> = std::result::Result<T, E>;


/// Create `Device` with serial interface by given `port` name/path.
#[cfg_attr(feature = "tracing", tracing::instrument(fields(port = port.as_ref())))]
pub async fn dev_with_port<S: AsRef<str>>(port: S) -> Result<Device> {
use serialport::SerialPort;

let name = port.as_ref();
let port = super::open(name)?;

let dev = port.as_ref()
.name()
.map(Cow::from)
.or(Some(name.into()))
.and_then(|name| {
let mut dev = SerialNumber::try_from(name.as_ref()).map_err(Error::from)
.and_then(|sn| usb::discover::device(&sn))
.ok()?;
let mut inter = super::Interface::new(unknown_serial_port_info(name));
inter.set_port(port);
dev.set_interface(crate::interface::Interface::Serial(inter));
Some(dev)
});

// TODO: error: device not found for serial port
dev.ok_or_else(|| Error::not_found())
}


#[cfg_attr(feature = "tracing", tracing::instrument())]
pub fn unknown_serial_port_info(port_name: Cow<'_, str>) -> serialport::SerialPortInfo {
let unknown = serialport::SerialPortType::Unknown;
serialport::SerialPortInfo { port_name: port_name.to_string(),
port_type: unknown }
}


/// Open given `interface` and read to stdout infinitely.
#[cfg(feature = "tokio-serial")]
#[cfg_attr(feature = "tracing", tracing::instrument(skip(interface), fields(interface.port_name = interface.info().port_name)))]
pub async fn redirect_interface_to_stdout(interface: &super::Interface) -> Result<(), Error> {
let mut out = tokio::io::stdout();
let mut port = interface.port
.as_ref()
.ok_or(Error::not_ready())?
.try_borrow_mut()?;
tokio::io::copy(port.as_mut(), &mut out).await
.map_err(Error::from)
.map(|bytes| debug!("Redirected {bytes} bytes to stdout."))
}

/// Open given `port` and read to stdout infinitely.
#[cfg(feature = "tokio-serial")]
#[cfg_attr(feature = "tracing", tracing::instrument(fields(port.name = serialport::SerialPort::name(port.as_ref()))))]
pub async fn redirect_port_to_stdout(port: &mut super::Port) -> Result<(), Error> {
let mut out = tokio::io::stdout();
tokio::io::copy(port, &mut out).await
.map_err(Error::from)
.map(|bytes| debug!("Redirected {bytes} bytes to stdout."))
}


/// Open port by given `port` name/path and read to stdout infinitely.
#[cfg(feature = "tokio-serial")]
#[cfg_attr(feature = "tracing", tracing::instrument())]
pub async fn redirect_to_stdout<S>(port: S) -> Result<(), Error>
where S: for<'a> Into<Cow<'a, str>> + std::fmt::Debug {
let mut port = super::open(port)?;
redirect_port_to_stdout(&mut port).await
}

/// Open port by given `port` name/path and read to stdout infinitely.
#[cfg_attr(feature = "tracing", tracing::instrument())]
pub fn redirect_to_stdout_blocking<S>(port: S) -> Result<(), Error>
where S: for<'a> Into<Cow<'a, str>> + std::fmt::Debug {
let mut port = super::open(port)?;

#[cfg(feature = "tokio-serial")]
{
let handle = tokio::runtime::Handle::current();
std::thread::spawn(move || {
let fut = redirect_port_to_stdout(&mut port);
let res = handle.block_on(fut);
if let Err(err) = res {
error!("Error redirecting to stdout: {err}");
}
}).join()
.expect("Error when join on the redirecting to stdout thread.");

Ok(())
}
#[cfg(not(feature = "tokio-serial"))]
{
let mut port = super::open(port)?;
let mut out = std::io::stdout();
std::io::copy(&mut port, &mut out).map_err(Error::from)
.map(|bytes| debug!("Redirected {bytes} bytes to stdout."))
}
}
131 changes: 131 additions & 0 deletions support/device/src/serial/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
use std::borrow::Cow;
use std::cell::RefCell;

use crate::error::Error;


pub mod discover;
mod blocking;
mod r#async;

mod methods;
pub use methods::*;


#[cfg(not(feature = "tokio-serial"))]
type Port = Box<dyn serialport::SerialPort>;
#[cfg(feature = "tokio-serial")]
type Port = Box<tokio_serial::SerialStream>;

pub struct Interface {
info: serialport::SerialPortInfo,
port: Option<RefCell<Port>>,
}


impl std::fmt::Display for Interface {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
use serialport::SerialPort;

let port_name = &self.info.port_name;
let name = self.port
.as_ref()
.map(|p| {
p.try_borrow()
.ok()
.map(|p| p.name().filter(|s| s != port_name))
.flatten()
})
.flatten();

write!(f, "serial:{}", name.as_deref().unwrap_or(port_name))
}
}

impl std::fmt::Debug for Interface {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Interface")
.field("name", &self.info.port_name)
.field("opened", &self.port.is_some())
.finish()
}
}


impl Interface {
#[cfg_attr(feature = "tracing", tracing::instrument)]
pub fn new(info: serialport::SerialPortInfo) -> Self { Self { info, port: None } }

#[cfg_attr(feature = "tracing", tracing::instrument)]
pub fn new_with(port: Port, name: Option<String>) -> Self {
use serialport::{SerialPort, SerialPortType, SerialPortInfo};

let name = port.name().or(name).map(Cow::from).unwrap_or_default();
let info = SerialPortInfo { port_name: name.to_string(),
port_type: SerialPortType::Unknown };

let mut result = Self::new(info);
result.set_port(port);
result
}

pub fn info(&self) -> &serialport::SerialPortInfo { &self.info }
pub fn is_open(&self) -> bool { self.port.is_some() }

#[cfg_attr(feature = "tracing", tracing::instrument)]
pub fn set_port(&mut self, port: Port) { self.port = Some(RefCell::new(port)); }

#[cfg_attr(feature = "tracing", tracing::instrument)]
pub fn open(&mut self) -> Result<(), Error> {
if self.port.is_some() {
Ok(())
} else {
let port = open(&self.info.port_name).map(RefCell::new)?;
self.port = Some(port);
Ok(())
}
}


#[cfg_attr(feature = "tracing", tracing::instrument)]
pub fn close(&mut self) { self.port.take(); }
}


#[cfg_attr(feature = "tracing", tracing::instrument)]
pub fn open<'a, S: Into<std::borrow::Cow<'a, str>>>(port_name: S) -> Result<Port, Error>
where S: std::fmt::Debug {
trace!("opening port {port_name:?}");
let builder = port_builder(port_name);

let port;
#[cfg(not(feature = "tokio-serial"))]
{
port = builder.open()?;
}
#[cfg(feature = "tokio-serial")]
{
use tokio_serial::SerialPortBuilderExt;
port = builder.open_native_async().map(Box::new)?;
}

{
use serialport::SerialPort;
let name = port.as_ref().name();
let name = name.as_deref().unwrap_or("n/a");
trace!("opened port: {name}");
}
Ok(port)
}

fn port_builder<'a>(port_name: impl Into<std::borrow::Cow<'a, str>>) -> serialport::SerialPortBuilder {
serialport::new(port_name, 115200).data_bits(serialport::DataBits::Eight)
}


/* NOTE: This can be safely sent between thread, but not inner port,
but that's okay because it's boxen under `RefCell`.
Probably should be pinned, but not sure yet.
*/
unsafe impl Send for Interface {}
unsafe impl Sync for Interface {}
179 changes: 179 additions & 0 deletions support/device/src/usb/discover.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
#[cfg(feature = "futures")]
use futures::{Stream, StreamExt};

use crate::device::query;
use crate::error::Error;
use crate::device::serial::SerialNumber as Sn;
use crate::{usb, serial, interface};
use crate::PRODUCT_ID_DATA;
use crate::PRODUCT_ID_STORAGE;
use crate::VENDOR_ID;

use super::Device;

type Result<T, E = Error> = std::result::Result<T, E>;


/// Enumerate all Playdate- devices.
#[cfg_attr(feature = "tracing", tracing::instrument)]
pub fn devices() -> Result<impl Iterator<Item = Device>> {
Ok(nusb::list_devices()?.filter(|d| d.vendor_id() == VENDOR_ID)
.map(|info| Device::new(info)))
}

/// Search Playdate- devices that in data (serial/modem/telnet) mode.
#[cfg_attr(feature = "tracing", tracing::instrument)]
pub fn devices_data() -> Result<impl Iterator<Item = Device>> {
devices().map(|iter| iter.filter(|d| d.info.product_id() == PRODUCT_ID_DATA))
}

/// Search Playdate- devices that in storage (data-disk) mode.
#[cfg_attr(feature = "tracing", tracing::instrument)]
pub fn devices_storage() -> Result<impl Iterator<Item = Device>> {
devices().map(|iter| iter.filter(|d| d.info.product_id() == PRODUCT_ID_STORAGE))
}

/// Search exact one device with same serial number.
#[cfg_attr(feature = "tracing", tracing::instrument)]
pub fn device(sn: &Sn) -> Result<Device> {
devices()?.find(|d| d.info.serial_number().filter(|s| sn.eq(s)).is_some())
.ok_or_else(|| Error::not_found())
}

/// Search devices with same serial number,
/// or __any__ Playdate- device if `sn` is `None`.
#[cfg_attr(feature = "tracing", tracing::instrument)]
pub fn devices_with(sn: Option<Sn>) -> Result<impl Iterator<Item = Device>> {
Ok(devices()?.filter(move |dev| {
if let Some(sn) = sn.as_ref() {
dev.info().serial_number().filter(|s| sn.eq(s)).is_some()
} else {
true
}
}))
}

/// Search devices with same serial number in data mode,
/// or __any__ Playdate- device if `sn` is `None`.
#[cfg_attr(feature = "tracing", tracing::instrument)]
pub fn devices_data_with(sn: Option<Sn>) -> Result<impl Iterator<Item = Device>> {
Ok(devices_data()?.filter(move |dev| {
if let Some(sn) = sn.as_ref() {
dev.info().serial_number().filter(|s| sn.eq(s)).is_some()
} else {
true
}
}))
}


#[cfg(feature = "futures")]
#[cfg_attr(feature = "tracing", tracing::instrument)]
pub async fn devices_data_for(query: query::Query) -> Result<Vec<Device>> {
use query::Value as Query;
use serial::dev_with_port;


let try_by_port = |port_pref: String| {
async {
let existing = serial::discover::ports().map(|ports| {
ports.into_iter()
.find(|p| p.port_name == port_pref)
.map(serial::Interface::new)
});
match existing {
Ok(Some(port)) => {
if let serialport::SerialPortType::UsbPort(serialport::UsbPortInfo { serial_number:
Some(ref sn),
.. }) = port.info().port_type
{
let name = port.info().port_name.as_str().to_owned();
Sn::try_from(sn.as_str()).map_err(Error::from)
.and_then(|sn| usb::discover::devices_data_with(Some(sn)))
.map(|mut devs| devs.next())
.map(move |mb| {
mb.map(|mut dev| {
dev.set_interface(interface::Interface::Serial(port));
dev
})
})
.map_err(|err| {
error!("Unable to map specified port {name} to device: {err}");
Error::chain(Error::not_found(), [err])
})
.ok()
.flatten()
.ok_or_else(Error::not_found)
} else {
dev_with_port(port_pref).await
}
},
Ok(None) => dev_with_port(port_pref).await,
Err(err) => {
dev_with_port(port_pref).await
.map_err(|err2| Error::chain(err2, [err]))
},
}
}
};


let devs = match query.value {
Some(Query::Path(port)) => {
vec![try_by_port(port.to_string_lossy().to_string()).await?]
},
Some(Query::Com(port)) => vec![try_by_port(format!("COM{port}")).await?],
Some(Query::Serial(sn)) => devices_data_with(Some(sn)).map(|i| i.collect())?,
None => devices_data_with(None).map(|i| i.collect())?,
};

Ok(devs)
}


#[cfg(feature = "futures")]
#[cfg_attr(feature = "tracing", tracing::instrument(skip(f)))]
pub async fn for_each_data_interface<F, Fut, T>(query: query::Query, mut f: F) -> Result<impl Stream<Item = T>>
where Fut: std::future::Future<Output = T>,
F: FnMut(interface::Interface) -> Fut {
use query::Value as Query;
use serial::unknown_serial_port_info;


let devs = match query.value {
Some(Query::Path(port)) => {
let name = port.to_string_lossy();
let mut interface = serial::Interface::new(unknown_serial_port_info(name));
interface.open()?;
let interface = interface::Interface::Serial(interface);
futures_lite::stream::once(f(interface).await).left_stream()
},
Some(Query::Com(port)) => {
let name = format!("COM{port}").into();
let mut interface = serial::Interface::new(unknown_serial_port_info(name));
interface.open()?;
let interface = interface::Interface::Serial(interface);
futures_lite::stream::once(f(interface).await).left_stream()
},
Some(Query::Serial(sn)) => {
let mut interfaces = Vec::new();
for mut dev in devices_data_with(Some(sn))? {
dev.open()?;
dev.interface()?;
interfaces.push(f(dev.interface.take().unwrap()).await);
}
futures_lite::stream::iter(interfaces).right_stream()
},
None => {
let mut interfaces = Vec::new();
for mut dev in devices_data_with(None)? {
dev.open()?;
dev.interface()?;
interfaces.push(f(dev.interface.take().unwrap()).await);
}
futures_lite::stream::iter(interfaces).right_stream()
},
};

Ok(devs)
}
146 changes: 146 additions & 0 deletions support/device/src/usb/io.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
use std::io::Write;

use futures_lite::future::block_on;


use futures_lite::StreamExt;
use nusb::transfer::RequestBuffer;
use nusb::{DeviceInfo, Interface};

use crate::device::Device;
use crate::error::Error;
use crate::serial::redirect_interface_to_stdout as redirect_serial_to_stdout;
use crate::usb::mode::DeviceMode;
use crate::usb::mode::Mode;
use crate::usb::BULK_IN;


#[cfg_attr(feature = "tracing", tracing::instrument(skip(interface)))]
pub fn read_interface(interface: &Interface,
buf_size: usize,
bufs: usize)
-> Result<impl futures_lite::stream::Stream<Item = Result<String, Error>>, Error> {
let mut inp = interface.bulk_in_queue(BULK_IN);

// preallocate buffers
while inp.pending() < bufs {
inp.submit(RequestBuffer::new(buf_size));
}

let stream = futures_lite::stream::poll_fn(move |ctx| {
inp.poll_next(ctx)
.map(|out| -> Result<_, Error> {
let data = out.into_result()?;
let s = std::str::from_utf8(&data)?.to_owned();
inp.submit(RequestBuffer::reuse(data, buf_size));
Ok(s)
})
.map(|out| Some(out))
});

Ok(stream)
}

#[cfg_attr(feature = "tracing", tracing::instrument(skip(interface, map)))]
pub fn read_while_map<F, T>(interface: &Interface,
buf_size: usize,
buffers: usize,
mut map: F)
-> Result<impl futures_lite::stream::Stream<Item = T>, Error>
where F: FnMut(&[u8]) -> Option<T>
{
let mut inp = interface.bulk_in_queue(BULK_IN);

// preallocate buffers
while inp.pending() < buffers {
inp.submit(RequestBuffer::new(buf_size));
}

let stream = futures_lite::stream::poll_fn(move |ctx| {
inp.poll_next(ctx).map(|out| -> Option<_> {
match out.into_result() {
Ok(data) => {
let res = map(data.as_slice());
if res.is_some() {
inp.submit(RequestBuffer::reuse(data, buf_size));
} else {
trace!("cancel all IN queue, by predicate.");
inp.cancel_all();
}
res
},
Err(err) => {
trace!("cancel all IN queue, by err: {err}.");
inp.cancel_all();
None
},
}
})
});

Ok(stream)
}


#[cfg_attr(feature = "tracing", tracing::instrument)]
pub fn read_once(device: DeviceInfo) -> Result<(String, Interface), Error> {
let mode = device.mode();
if !matches!(mode, Mode::Data) {
return Err(Error::WrongState(mode));
}


let device = device.open()?;
let inter = device.claim_interface(1)?;

let stream = read_while_map(&inter, 256, 2, |data| {
match std::str::from_utf8(&data) {
Ok(s) => {
if s.trim().is_empty() {
None
} else {
Some(s.to_owned())
}
},
Err(err) => {
error!("{err:?}");
None
},
}
})?.fold(String::new(), |acc, ref s| acc + s);
let s = block_on(stream);
Ok((s, inter))
}


#[cfg_attr(feature = "tracing", tracing::instrument)]
pub async fn redirect_to_stdout(device: &mut Device) -> Result<(), Error> {
let mode = device.mode();
if !matches!(mode, Mode::Data) {
return Err(Error::WrongState(mode));
}

device.open()?;
redirect_interface_to_stdout(device.interface_mut()?).await?;

Ok(())
}

#[cfg_attr(feature = "tracing", tracing::instrument)]
pub async fn redirect_interface_to_stdout(interface: &mut crate::interface::Interface) -> Result<(), Error> {
match interface {
crate::interface::Interface::Usb(interface) => {
let mut stdout = std::io::stdout();
let to_stdout = move |data: &[u8]| stdout.write_all(data).inspect_err(|err| error!("{err}")).ok();
let stream = read_while_map(&interface.inner, 256, 2, to_stdout)?;
if let Some(_) = stream.last().await {
trace!("Read stream complete.");
}
},
crate::interface::Interface::Serial(interface) => {
interface.open()?;
redirect_serial_to_stdout(interface).await?;
},
}
Ok(())
}
340 changes: 340 additions & 0 deletions support/device/src/usb/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,340 @@
use std::borrow::Cow;

use std::pin::Pin;
use std::task::Context;
use std::task::Poll;

use futures::FutureExt;
use futures::TryFutureExt;
use nusb::transfer::RequestBuffer;
use nusb::transfer::TransferError;
use nusb::DeviceInfo;
use nusb::InterfaceInfo;
use object_pool::Pool;
use object_pool::Reusable;

use crate::device::command::Command;
use crate::device::Device;
use crate::error::Error;

use self::mode::DeviceMode;
use self::mode::Mode;

pub mod mode;
pub mod discover;
pub mod io;


const BULK_IN: u8 = 0x81;
const BULK_OUT: u8 = 0x01;

#[allow(dead_code)]
const INTERRUPT_IN: u8 = 0x82;


pub trait HaveDataInterface {
fn data_interface_number(&self) -> Option<u8>;
fn have_data_interface(&self) -> bool { self.data_interface_number().is_some() }
}

impl HaveDataInterface for DeviceInfo {
#[cfg_attr(feature = "tracing", tracing::instrument)]
fn data_interface_number(&self) -> Option<u8> {
self.interfaces()
.find(|i| i.class() == 0xA | 2)
.map(|i| i.interface_number())
}
}

impl HaveDataInterface for nusb::Device {
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
fn data_interface_number(&self) -> Option<u8> {
let cfg = self.active_configuration().ok()?;
for i in cfg.interfaces() {
let bulk = i.alt_settings().find(|i| i.class() == 0xA | 2);
if bulk.is_some() {
return bulk.map(|i| i.interface_number());
}
}
None
}
}

impl HaveDataInterface for Device {
#[cfg_attr(feature = "tracing", tracing::instrument)]
fn data_interface_number(&self) -> Option<u8> {
self.info
.data_interface_number()
.or_else(|| self.inner.as_ref()?.data_interface_number())
}
}

pub trait MassStorageInterface {
fn storage_interface(&self) -> Option<&InterfaceInfo>;
fn have_storage_interface(&self) -> bool;
}

impl MassStorageInterface for DeviceInfo {
#[cfg_attr(feature = "tracing", tracing::instrument)]
fn storage_interface(&self) -> Option<&InterfaceInfo> { self.interfaces().find(|i| i.class() == 8) }
#[cfg_attr(feature = "tracing", tracing::instrument)]
fn have_storage_interface(&self) -> bool { self.storage_interface().is_some() }
}


impl Device {
/// 1. Find this device
/// 1. Compare `mode` of `this` vs. just found
/// 1. [if changed] Update state of `this`, drop all pending transfers if needed
/// to prevent future errors when send to unexisting interface.
/// 1. Return `true` if `mode` changed.
#[cfg_attr(feature = "tracing", tracing::instrument)]
pub fn refresh(&mut self) -> Result<bool, Error> {
let mode = self.info.mode();
if mode != self.mode {
self.mode = mode;
self.interface.take();
self.inner.take();
debug!(
"{}: refreshed by existing.",
self.info.serial_number().unwrap_or("unknown")
);
Ok(true)
} else {
let updated = crate::usb::discover::devices()?.find(|dev| {
let serial = dev.info().serial_number();
serial.is_some() && serial == self.info.serial_number()
});
if let Some(dev) = updated {
let mode = dev.mode_cached();
let changed = mode != self.mode;
if changed {
self.mode = mode;
self.info = dev.info;
self.interface.take();
self.inner.take();
debug!(
"{}: refreshed by existing new.",
self.info.serial_number().unwrap_or("unknown")
);
}
Ok(changed)
} else {
debug!(
"{}: device not found.",
self.info.serial_number().unwrap_or("unknown")
);
self.interface.take();
self.inner.take();
Ok(true)
}
}
}


/// Open USB interface if available,
/// otherwise try open serial port if available.
#[cfg_attr(feature = "tracing", tracing::instrument)]
pub fn open(&mut self) -> Result<(), Error> {
if !matches!(self.mode, Mode::Data) {
return Err(Error::WrongState(self.mode));
}

trace!("opening device");

// Special case: if we already have an interface, mostly possible serial:
if self.interface.is_some() {
return match self.interface_mut()? {
crate::interface::Interface::Serial(i) => i.open(),
_ => Ok(()),
};
}

if self.have_data_interface() {
let bulk = self.try_bulk().map(|_| {});
if let Some(err) = bulk.err() {
self.try_serial().map_err(|err2| Error::chain(err2, [err]))
} else {
self.interface()
}
} else {
self.try_serial()
}?;
Ok(())
}

#[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
fn try_bulk(&mut self) -> Result<&crate::interface::Interface, Error> {
if let Some(ref io) = self.interface {
Ok(io)
} else if let Some(ref dev) = self.inner {
let id = self.info
.data_interface_number()
.or_else(|| dev.data_interface_number())
.ok_or_else(|| Error::not_found())?;
// previously used 0x01.
self.interface = Some(Interface::from(dev.claim_interface(id)?).into());
Ok(self.interface.as_ref().unwrap())
} else {
let dev = self.info.open()?;
let id = self.info
.data_interface_number()
.or_else(|| dev.data_interface_number())
.ok_or_else(|| Error::not_found())?;
self.interface = Some(Interface::from(dev.claim_interface(id)?).into());
self.inner = Some(dev);
Ok(self.interface.as_ref().unwrap())
}
}

#[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
fn try_serial(&mut self) -> Result<&crate::interface::Interface, Error> {
use crate::serial::Interface;


let mut errors = Vec::new();
let port = {
crate::serial::discover::ports_for(&self).map(|ports| ports.map(|port| Interface::new(port)))?
.find_map(|mut port| {
// try to open port, we could get an permission error
match port.open() {
Ok(_) => Some(port),
Err(err) => {
errors.push(err);
None
},
}
})
};

if let Some(port) = port {
self.interface = Some(port.into());
self.interface()
} else {
Err(Error::chain(Error::not_found(), errors))
}
}


/// Async read-write interface.
pub fn interface(&self) -> Result<&crate::interface::Interface, Error> {
self.interface.as_ref().ok_or_else(|| Error::not_ready())
}

pub fn interface_mut(&mut self) -> Result<&mut crate::interface::Interface, Error> {
self.interface.as_mut().ok_or_else(|| Error::not_ready())
}

#[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
pub fn set_interface(&mut self, interface: crate::interface::Interface) {
self.close();
self.interface = Some(interface);
}

#[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
pub fn close(&mut self) {
self.info.serial_number().map(|s| debug!("closing {s}"));
self.interface.take();
self.inner.take();
}

#[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
pub fn close_with_reset(&mut self) {
self.info.serial_number().map(|s| debug!("closing* {s}"));
self.interface.take();
if let Some(dev) = self.inner.take() {
dev.reset().map_err(|err| error!("{err}")).ok();
}
}
}

impl crate::interface::blocking::Out for Interface {
#[cfg_attr(feature = "tracing", tracing::instrument)]
fn send_cmd(&self, cmd: Command) -> Result<usize, Error> {
use crate::interface::r#async;
let fut = <Self as r#async::Out>::send_cmd(self, cmd);
futures_lite::future::block_on(fut).map_err(Into::into)
}
}

impl crate::interface::blocking::In for Interface {}


impl crate::interface::r#async::Out for Interface {
#[cfg_attr(feature = "tracing", tracing::instrument)]
async fn send(&self, data: &[u8]) -> Result<usize, Error> { self.write(data).map_err(Into::into).await }
}

impl crate::interface::r#async::In for Interface {}


pub struct Interface {
inner: nusb::Interface,
}

impl From<nusb::Interface> for Interface {
fn from(interface: nusb::Interface) -> Self { Self { inner: interface } }
}

impl std::fmt::Display for Interface {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "usb") }
}

impl std::fmt::Debug for Interface {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "usb") }
}

impl Interface {
#[cfg_attr(feature = "tracing", tracing::instrument)]
pub fn write(&self, data: &[u8]) -> impl std::future::Future<Output = Result<usize, TransferError>> {
trace!("writing {} bytes", data.len());
self.inner.bulk_out(BULK_OUT, data.to_vec()).map(|comp| {
// TODO: attach data to the pool
let written = comp.data.actual_length();
let data = comp.data.reuse();
let s = std::str::from_utf8(&data).map(Cow::Borrowed)
.unwrap_or_else(|_| {
Cow::Owned(hex::encode_upper(&data))
});
trace!("sent, resp: ({written}) '{s}'");
comp.status.map(|_| written)
})
}
}


pub struct PoolStream<'pool> {
pool: &'pool Pool<Vec<u8>>,
queue: nusb::transfer::Queue<RequestBuffer>,
buffer_size: usize,
// inner: futures_lite::stream::PollFn<Option<Result<Vec<u8>, TransferError>>>,
}

impl<'pool> futures::Stream for PoolStream<'pool> {
type Item = Result<Reusable<'pool, Vec<u8>>, TransferError>;

fn poll_next(mut self: Pin<&mut Self>, ctx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
self.queue.poll_next(ctx).map(|comp| {
let data = comp.data;
match comp.status {
Ok(_) => {
// prepare next request
let buffer_size = self.buffer_size;
let (_, buf) =
self.pool.pull(|| Vec::with_capacity(buffer_size)).detach();
self.queue.submit(RequestBuffer::reuse(buf, buffer_size));
// make received data reusable
let data = Reusable::new(self.pool, data);
Some(Ok(data))
},
Err(err) => {
self.pool.attach(data);
self.queue.cancel_all();
Some(Err(err))
},
}
})
}

fn size_hint(&self) -> (usize, Option<usize>) { (0, Some(self.queue.pending())) }
}
46 changes: 46 additions & 0 deletions support/device/src/usb/mode.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
use nusb::DeviceInfo;

use crate::PRODUCT_ID_DATA;
use crate::PRODUCT_ID_STORAGE;


#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
/// DATA / COMM
Data,
/// MASS_STORAGE
Storage,
Unknown,
}

impl std::fmt::Display for Mode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", match self {
Mode::Data => 'D',
Mode::Storage => 'S',
Mode::Unknown => '?',
})
}
}


pub trait DeviceMode {
/// USB device mode determined by the product ID.
fn mode(&self) -> Mode;
}


impl DeviceMode for DeviceInfo {
fn mode(&self) -> Mode {
match self.product_id() {
PRODUCT_ID_DATA => Mode::Data,
PRODUCT_ID_STORAGE => Mode::Storage,
_ => Mode::Unknown,
}
}
}


impl DeviceMode for super::Device {
fn mode(&self) -> Mode { self.info().mode() }
}
28 changes: 28 additions & 0 deletions support/sim-ctrl/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
[package]
name = "playdate-simulator-utils"
version = "0.1.0"
readme = "README.md"
description = "Cross-platform utils to deal with Playdate Simulator."
keywords = ["playdate", "sdk", "utils"]
categories = ["development-tools"]
edition.workspace = true
license.workspace = true
authors.workspace = true
homepage.workspace = true
repository.workspace = true


[dependencies]
thiserror.workspace = true
log.workspace = true
tracing = { version = "0.1", optional = true }

[dependencies.utils]
workspace = true
default-features = false

[dependencies.tokio]
features = ["process"]
default-features = false
workspace = true
optional = true
44 changes: 44 additions & 0 deletions support/sim-ctrl/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Playdate Simulator Utils

Cross-platform utils to do things with Playdate Simulator.


Usage:

```rust
let pdx = PathBuf::from("path/to/my-game.pdx");
let sdk = PathBuf::from("path/to/playdate-sdk");

// Create a future with command execution:
simulator::run::run(&pdx, Some(&sdk)).await;

// Or create a command and do whatever:
let mut cmd = simulator::run::command(&pdx, Some(&sdk)).unwrap();
let stdout = cmd.output().unwrap().stdout;
println!("Sim output: {}", std::str::from_utf8(&stdout).unwrap());
```


## Prerequisites

1. Rust __nightly__ toolchain
3. [Playdate SDK][sdk] with Simulator
- Ensure that env var `PLAYDATE_SDK_PATH` points to the SDK root. _This is optional, but good move to help the tool to find SDK, and also useful if you have more then one version of SDK._


[playdate-website]: https://play.date
[sdk]: https://play.date/dev/#cardSDK



## State

Early development state.

There is just one method to run pdx with sim now.



- - -

This software is not sponsored or supported by Panic.
17 changes: 17 additions & 0 deletions support/sim-ctrl/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#![feature(error_generic_member_access)]
#![feature(exit_status_error)]

#[macro_use]
#[cfg(feature = "tracing")]
extern crate tracing;

#[macro_use]
#[cfg(not(feature = "tracing"))]
extern crate log;

pub extern crate utils;

pub use utils::toolchain::sdk::Sdk;


pub mod run;
64 changes: 64 additions & 0 deletions support/sim-ctrl/src/run.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
use std::path::Path;
use std::io::{Error as IoError, ErrorKind as IoErrorKind};

use utils::toolchain::sdk::Sdk;


#[cfg_attr(feature = "tracing", tracing::instrument)]
pub async fn run(pdx: &Path, sdk: Option<&Path>) -> Result<(), Error> {
#[allow(unused_mut)]
let mut cmd = command(&pdx, sdk.as_deref())?;
#[cfg(feature = "tokio")]
let mut cmd = tokio::process::Command::from(cmd);

trace!("executing: {cmd:?}");

#[cfg(feature = "tokio")]
cmd.status().await?.exit_ok()?;
#[cfg(not(feature = "tokio"))]
cmd.status()?.exit_ok()?;

Ok(())
}


#[cfg_attr(feature = "tracing", tracing::instrument)]
pub fn command(pdx: &Path, sdk: Option<&Path>) -> Result<std::process::Command, Error> {
let sdk = sdk.map_or_else(|| Sdk::try_new(), Sdk::try_new_exact)?;

let (pwd, sim) = if cfg!(target_os = "macos") {
("Playdate Simulator.app/Contents/MacOs", "./Playdate Simulator")
} else if cfg!(unix) {
(".", "./PlaydateSimulator")
} else if cfg!(windows) {
(".", "PlaydateSimulator.exe")
} else {
return Err(IoError::new(IoErrorKind::Unsupported, "Unsupported platform").into());
};

let mut cmd = std::process::Command::new(sim);
cmd.current_dir(sdk.bin().join(pwd));
cmd.arg(&pdx);

Ok(cmd)
}


pub use error::*;
mod error {
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error(transparent)]
Io {
#[backtrace]
#[from]
source: std::io::Error,
},
#[error(transparent)]
Exec {
#[backtrace]
#[from]
source: std::process::ExitStatusError,
},
}
}
50 changes: 35 additions & 15 deletions support/tool/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "playdate-tool"
version = "0.1.4"
version = "0.2.0"
readme = "README.md"
description = "Tool for interaction with Playdate device and sim."
keywords = ["playdate", "usb", "utility"]
@@ -15,32 +15,52 @@ repository.workspace = true
[[bin]]
path = "src/main.rs"
name = "pdtool"
required-features = ["cli"]


[dependencies]
regex.workspace = true
log.workspace = true
env_logger = { workspace = true, optional = true }
thiserror = "1.0"
# RT, async:
tokio = { version = "1.36", features = ["full", "rt-multi-thread"] }
futures = { version = "0.3" }
futures-lite.workspace = true

# fmt:
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
plist = "1.6"

rusb = { version = "0.9", optional = true }
usb-ids = { version = "1.2023.0", optional = true }
# CLI:
log.workspace = true
env_logger.workspace = true
thiserror.workspace = true
miette = { version = "7.2", features = ["fancy"] }

# used assert::env-resolver and sdk
build = { workspace = true, default-features = false }
# tracing:
tracing = { version = "0.1", optional = true }
tracing-subscriber = { version = "0.3", optional = true }
console-subscriber = { version = "0.2", features = [
"env-filter",
], optional = true }

[dependencies.clap]
features = ["std", "env", "derive", "help", "usage", "color"]
workspace = true
optional = true

# PD:
[dependencies.device]
features = ["async", "tokio", "clap", "tokio-serial"]
workspace = true

[dependencies.simulator]
features = ["tokio"]
workspace = true


[features]
default = ["usb"]
cli = ["clap", "env_logger"]
usb = ["rusb", "usb-ids"]
tracing = [
"dep:tracing",
"tracing-subscriber",
"device/tracing",
"simulator/tracing",
]

# Useful with [tokio-console](https://tokio.rs/tokio/topics/tracing-next-steps)
tokio-tracing = ["tracing", "tokio/tracing", "console-subscriber", "tracing"]
74 changes: 52 additions & 22 deletions support/tool/README.md
Original file line number Diff line number Diff line change
@@ -1,49 +1,79 @@
# Playdate Tool

CLI-tool and lib for interaction with Playdate device and sim.
Cross-platform CLI-tool for interaction with [Playdate][playdate-website] device and simulator.


### Status
Can do for you:
- operate with multiple devices simultaneously
- find connected devices, filter by mode, state, serial-number
- send commands
- read from devices
- mount as drive (mass storage usb)
- unmount
- install pdx (playdate package)
- run pdx (optionally with install pdx before run)
- operate with simulator
- run pdx
- read output from sim.

This is earlier version, that means "alpha" or "MVP".
API can be changed in future versions.
Global __refactoring is planned__ with main reason of properly work with usb on all platforms.

Currently tested and works good on following platforms:
- Unix (x86-64 and aarch64)
- macos 👍
- linux 👍
- Windows (x86-64 and aarch64)
- ⚠️ known issues with hardware lookup, work in progress.

Tested on following platforms:
- MacOs
- Linux
- Windows


## Prerequisites

To build playdate-tool you're need:
1. Rust __nightly__ toolchain
2. Probably `libusb` and `pkg-config` or `vcpkg`, follow [instructions for rusb crate][rusb].

To use playdate-tool you're need:
1. [Playdate SDK][sdk]
- Ensure that env var `PLAYDATE_SDK_PATH` points to the SDK root
2. This tool.
2. Linux only:
- `libudev`, follow [instructions for udev crate][udev-crate-deps].
3. [Playdate SDK][sdk] with simulator
- Ensure that env var `PLAYDATE_SDK_PATH` points to the SDK root. _This is optional, but good move to help the tool to find SDK, and also useful if you have more then one version of SDK._


[playdate-website]: https://play.date
[udev-crate-deps]: https://crates.io/crates/udev#Dependencies
[sdk]: https://play.date/dev/#cardSDK
[doc-prerequisites]: https://sdk.play.date/Inside%20Playdate%20with%20C.html#_prerequisites
[rusb]: https://crates.io/crates/rusb



## Installation

```bash
cargo install playdate-tool --features=cli
cargo install playdate-tool
```


## Usage

```bash
pdtool --help
```

<details><summary>Help output example</summary>


```text
Usage: pdtool [OPTIONS] <COMMAND>
Commands:
list Print list of connected active Playdate devices
mount Mount a Playdate device if specified, otherwise mount all Playdates as possible
unmount Unmount a Playdate device if specified, otherwise unmount all mounted Playdates
install Install given package to device if specified, otherwise use all devices as possible
run Install and run given package on the specified device or simulator
read Connect to device and proxy output to stdout
send Send command to specified device
help Print this message or the help of the given subcommand(s)
Options:
--format <FORMAT> Standard output format [default: human] [possible values: human, json]
-h, --help Print help
-V, --version Print version
```

</details>


- - -
230 changes: 230 additions & 0 deletions support/tool/src/cli.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
use std::path::PathBuf;

use clap::{Parser, Subcommand, ValueEnum};
use simulator::utils::consts::SDK_ENV_VAR;

use crate::device::query::Query;


pub fn parse() -> Cfg { Cfg::parse() }


#[derive(Parser, Debug)]
#[command(author, version, about, name = "pdtool")]
pub struct Cfg {
#[command(subcommand)]
pub cmd: Command,

/// Standard output format.
#[clap(long, global = true, default_value_t = Format::Human)]
pub format: Format,
}


#[derive(ValueEnum, Debug, Clone, Copy)]
pub enum Format {
Human,
Json,
}

impl std::fmt::Display for Format {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Format::Human => "human".fmt(f),
Format::Json => "json".fmt(f),
}
}
}


#[derive(Subcommand, Debug)]
pub enum Command {
/// Print list of connected active Playdate devices.
List {
#[arg(default_value_t = DeviceKind::Any)]
kind: DeviceKind,
},

/// Mount a Playdate device if specified, otherwise mount all Playdates as possible.
Mount {
#[command(flatten)]
query: Query,
/// Wait for availability of mounted device's filesystem.
#[arg(long, default_value_t = false)]
wait: bool,
},

/// Unmount a Playdate device if specified, otherwise unmount all mounted Playdates.
Unmount {
/// Device spec
#[command(flatten)]
query: Query,
/// Wait for device to be connected after unmounted.
#[arg(long, default_value_t = false)]
wait: bool,
},

/// Install given package to device if specified, otherwise use all devices as possible.
///
/// Workflow: switch to storage mode and mount if needed, write files, unmount if requested.
Install(#[command(flatten)] Install),

/// Install and run given package on the specified device or simulator.
Run(#[command(flatten)] run::Run),

/// Connect to device and proxy output to stdout.
Read(#[command(flatten)] Query),

/// Send command to specified device.
// #[command(hide = true)]
Send(#[command(flatten)] Send),

/// Debug functions, only for development purposes.
#[cfg(debug_assertions)]
Debug(#[command(flatten)] Dbg),
}


#[derive(Clone, Debug, clap::Parser)]
pub struct Dbg {
/// Command to send:
#[clap(subcommand)]
pub cmd: DbgCmd,

/// Device selector.
#[command(flatten)]
pub query: Query,
}

#[derive(Debug, Clone, clap::Subcommand)]
pub enum DbgCmd {
/// Inspect device(s) state.
Inspect,
}


#[derive(ValueEnum, Debug, Clone, Copy)]
pub enum DeviceKind {
Any,
Data,
Storage,
}

impl ToString for DeviceKind {
fn to_string(&self) -> String {
match self {
DeviceKind::Any => "any",
DeviceKind::Data => "data",
DeviceKind::Storage => "storage",
}.to_owned()
}
}


#[derive(Clone, Debug, clap::Parser)]
#[command(author, version, about, long_about = None, name = "install")]
pub struct Install {
/// Path to the PDX package.
#[arg(value_name = "PACKAGE")]
pub pdx: PathBuf,

/// Allow to overwrite existing files.
#[arg(long, default_value_t = false)]
pub force: bool,

#[command(flatten)]
pub query: Query,
}


#[derive(Clone, Debug, clap::Parser)]
#[command(author, version, about, long_about = None, name = "send")]
pub struct Send {
/// Command to send:
#[clap(subcommand)]
pub command: device::device::command::Command,

/// Device selector.
#[command(flatten)]
pub query: Query,

/// Read output from device after sending command.
#[arg(long, default_value_t = false)]
pub read: bool,
}


pub use run::*;
mod run {
use std::borrow::Cow;

use super::*;


#[derive(Clone, Debug, clap::Parser)]
#[command(author, version, about, long_about = None, name = "run")]
pub struct Run {
#[clap(subcommand)]
pub destination: Destination,
}


#[derive(Clone, Debug, clap::Subcommand)]
pub enum Destination {
/// Install to the device.
///
/// Attention: <PACKAGE> parameter is a local path to pdx-package.
/// But in case of '--no-install' given path will be interpreted as on-device relative to it's root path,
/// e.g. "/Games/my-game.pdx".
#[clap(visible_alias("dev"))]
Device(Dev),

/// Run with simulator.
/// Playdate required to be installed.
#[clap(visible_alias("sim"))]
Simulator(Sim),
}

impl std::fmt::Display for Destination {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let name: Cow<str> = match self {
Destination::Device(Dev { install: Install { query, .. },
.. }) => format!("device:{query}").into(),
Destination::Simulator(_) => "simulator".into(),
};
name.fmt(f)
}
}


#[derive(Clone, Debug, clap::Parser)]
/// Simulator destination
pub struct Sim {
/// Path to the PDX package.
#[arg(value_name = "PACKAGE")]
pub pdx: PathBuf,

/// Path to Playdate SDK
#[arg(long, env = SDK_ENV_VAR, value_name = "DIRECTORY", value_hint = clap::ValueHint::DirPath)]
pub sdk: Option<PathBuf>,
}


#[derive(Clone, Debug, clap::Parser)]
/// Hardware destination
pub struct Dev {
#[command(flatten)]
pub install: super::Install,

/// Do not install pdx to the device.
/// If set, <PACKAGE> path will be interpreted as on-device path of already installed package,
/// relative to the root of device's fs partition.
#[arg(long, name = "no-install", default_value_t = false)]
pub no_install: bool,

/// Do not wait & read the device's output after execution.
/// Exits immediately after send 'run' command.
#[arg(long, name = "no-read", default_value_t = false)]
pub no_read: bool,
}
}
183 changes: 0 additions & 183 deletions support/tool/src/cli/commands/install.rs

This file was deleted.

129 changes: 0 additions & 129 deletions support/tool/src/cli/commands/mod.rs

This file was deleted.

57 changes: 0 additions & 57 deletions support/tool/src/cli/commands/mount.rs

This file was deleted.

15 changes: 0 additions & 15 deletions support/tool/src/cli/commands/read.rs

This file was deleted.

278 changes: 0 additions & 278 deletions support/tool/src/cli/commands/run.rs

This file was deleted.

33 changes: 0 additions & 33 deletions support/tool/src/cli/mod.rs

This file was deleted.

122 changes: 0 additions & 122 deletions support/tool/src/io/mod.rs

This file was deleted.

123 changes: 0 additions & 123 deletions support/tool/src/lib.rs

This file was deleted.

Loading