From 8c50eb0f841cab7d8cfbb125d55df6a16d1bbd22 Mon Sep 17 00:00:00 2001 From: Jika Date: Wed, 29 Oct 2025 18:03:54 +0100 Subject: [PATCH] Redstone device --- README.md | 57 ++++++++++++--- examples/invoke-method.rs | 12 ++-- examples/list-devices.rs | 2 + examples/redstone.rs | 62 ++++++++++++++++ src/bus.rs | 27 +++++++ src/devices/mod.rs | 1 + src/devices/redstone.rs | 144 ++++++++++++++++++++++++++++++++++++++ src/lib.rs | 14 ++-- src/value.rs | 127 +++++++++++++++++++++++++++++++++ 9 files changed, 426 insertions(+), 20 deletions(-) create mode 100644 examples/redstone.rs create mode 100644 src/devices/mod.rs create mode 100644 src/devices/redstone.rs create mode 100644 src/value.rs diff --git a/README.md b/README.md index f6a6681..fd50934 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ the console is switched into raw mode before use. Add the crate to your workspace and interact with it directly: ```rust -use oc2r_rust::{DeviceBus, JsonValue, Result, DEFAULT_DEVICE_PATH}; +use oc2r_rust::{DeviceBus, Result, DEFAULT_DEVICE_PATH}; fn main() -> Result<()> { let mut bus = DeviceBus::connect(DEFAULT_DEVICE_PATH)?; @@ -33,7 +33,7 @@ fn main() -> Result<()> { // Invoke a method on the first device as an example. if let Some(mut device) = bus.device("some-device-id")? { - let response = device.invoke("help", &[JsonValue::Null])?; + let response = device.call("help", ())?; println!("help(): {response:?}"); } @@ -41,22 +41,63 @@ fn main() -> Result<()> { } ``` -### Running the examples +### Dynamic method calls + +`Device::call` (and the matching `DeviceBus::call`) accept anything that +implements [`IntoJsonArgs`](https://docs.rs/oc2r-rust/latest/oc2r_rust/trait.IntoJsonArgs.html), +so you can stay close to the Lua ergonomics: + +```rust +device.call("setRedstoneOutput", ("east", 15_u8))?; +device.call("getRedstoneInput", ("north",))?; +``` + +### Typed helpers + +For common peripherals you can rely on typed wrappers. The redstone helper +mirrors the behaviour of `redstone.lua` and performs basic clamping/parsing: + +```rust +use oc2r_rust::devices::redstone::{Redstone, Side}; +use oc2r_rust::{DeviceBus, Result, DEFAULT_DEVICE_PATH}; + +fn main() -> Result<()> { + let mut bus = DeviceBus::connect(DEFAULT_DEVICE_PATH)?; + let mut redstone = Redstone::attach(&mut bus)? + .expect("no redstone interface attached"); + + redstone.set_output_state(Side::East, true)?; + println!("in: {}", redstone.input(Side::East)?); + Ok(()) +} +``` + +### Building the examples Two runnable examples live in `examples/`: - `list-devices` – enumerate devices and pretty-print their metadata - `invoke-method` – list available methods for the first device and invoke one +- `redstone` – toggle/query a redstone interface, mirroring `redstone.lua` -Run them with: +The OC2R VM does not ship a Rust toolchain, so build the binaries on your host +and copy them into the VM: ```bash -cargo run --example list-devices -cargo run --example invoke-method +# once +cargo install cross --git https://github.com/cross-rs/cross + +# build release binaries +cross build --release --example list-devices +cross build --release --example invoke-method +cross build --release --example redstone ``` -Both examples expect to run inside the OC2R VM or a compatible environment -where `/dev/hvc0` is exposed. +The resulting executables live under +`target//release/examples/`. Copy them into Minux using your preferred +workflow—an Import/Export card in Minecraft works well if you drop the binary +onto the card outside the VM. Once the file is on disk (e.g. `/usr/bin/`), you +can execute it from the shell. ## Architecture overview diff --git a/examples/invoke-method.rs b/examples/invoke-method.rs index 0cd3b08..1be0d86 100644 --- a/examples/invoke-method.rs +++ b/examples/invoke-method.rs @@ -1,3 +1,5 @@ +// Build with: cross build --release --example invoke-method + use oc2r_rust::{DEFAULT_DEVICE_PATH, DeviceBus, Result}; fn main() -> Result<()> { @@ -22,20 +24,14 @@ fn main() -> Result<()> { if let Some(method) = methods.iter().find(|m| m.parameters.is_empty()) { println!("Invoking {}()", method.name); - let result = device.invoke(&method.name, &[])?; + let result = device.call(&method.name, ())?; println!("Result: {result:?}"); } else { println!("No zero-argument methods available to invoke."); } // Example for passing parameters: - // device.invoke( - // "setOutput", - // &[ - // oc2r_rust::JsonValue::Number(1.into()), - // oc2r_rust::JsonValue::Bool(true), - // ], - // )?; + // device.call("setRedstoneOutput", ("east", 15_u8))?; Ok(()) } diff --git a/examples/list-devices.rs b/examples/list-devices.rs index adaebdf..9a16132 100644 --- a/examples/list-devices.rs +++ b/examples/list-devices.rs @@ -1,3 +1,5 @@ +// Build with: cross build --release --example list-devices + use oc2r_rust::{DEFAULT_DEVICE_PATH, DeviceBus, Result}; fn main() -> Result<()> { diff --git a/examples/redstone.rs b/examples/redstone.rs new file mode 100644 index 0000000..c2ea454 --- /dev/null +++ b/examples/redstone.rs @@ -0,0 +1,62 @@ +// Build with: cross build --release --example redstone + +use oc2r_rust::devices::redstone::{Redstone, Side}; +use oc2r_rust::{DEFAULT_DEVICE_PATH, DeviceBus, Result}; +use std::env; +use std::str::FromStr; + +fn main() -> Result<()> { + let mut args = env::args().skip(1); + let Some(side_arg) = args.next() else { + print_usage(); + return Ok(()); + }; + + let mut bus = DeviceBus::connect(DEFAULT_DEVICE_PATH)?; + let mut redstone = match Redstone::attach(&mut bus)? { + Some(device) => device, + None => { + eprintln!("No redstone interface found on this bus."); + return Ok(()); + } + }; + + let side = match Side::from_str(&side_arg) { + Ok(side) => side, + Err(_) => { + eprintln!("Invalid side: {side_arg}"); + return Ok(()); + } + }; + + if let Some(value) = args.next() { + let level = parse_level(&value); + redstone.set_output_level(side, level)?; + } + + let input = redstone.input(side)?; + let output = redstone.output(side)?; + println!("in: {}", input.ceil()); + println!("out: {}", output.ceil()); + + Ok(()) +} + +fn parse_level(value: &str) -> u8 { + let trimmed = value.trim(); + if let Ok(num) = trimmed.parse::() { + return num.min(15); + } + + match trimmed.to_ascii_lowercase().as_str() { + "true" | "on" | "yes" => 15, + "false" | "off" | "no" => 0, + _ => 0, + } +} + +fn print_usage() { + eprintln!("Usage: redstone [value]"); + eprintln!(" side : up|down|north|south|west|east|front|back|left|right"); + eprintln!(" value : 0-15, true/on/yes, false/off/no"); +} diff --git a/src/bus.rs b/src/bus.rs index 6a49d80..cb4943e 100644 --- a/src/bus.rs +++ b/src/bus.rs @@ -1,6 +1,7 @@ use crate::error::{Error, Result}; use crate::rpc::{DeviceEntry, JsonValue, MethodEntry, RpcRequest, RpcResponse}; use crate::transport::{flush_fd, read_message, set_raw, write_message}; +use crate::value::IntoJsonArgs; use std::fs::OpenOptions; use std::os::fd::{AsRawFd, RawFd}; use std::path::Path; @@ -65,6 +66,24 @@ impl DeviceBus { device_id: &str, name: &str, parameters: &[JsonValue], + ) -> Result { + self.invoke_raw(device_id, name, parameters) + } + + /// Invoke a method with arguments that implement [`IntoJsonArgs`]. + pub fn call

(&mut self, device_id: &str, name: &str, params: P) -> Result + where + P: IntoJsonArgs, + { + let args = params.into_args(); + self.invoke_raw(device_id, name, &args) + } + + fn invoke_raw( + &mut self, + device_id: &str, + name: &str, + parameters: &[JsonValue], ) -> Result { self.flush()?; let request = RpcRequest::Invoke { @@ -145,4 +164,12 @@ impl<'bus> Device<'bus> { pub fn invoke(&mut self, name: &str, parameters: &[JsonValue]) -> Result { self.bus.invoke(&self.entry.device_id, name, parameters) } + + /// Invoke `name` with arguments converted via [`IntoJsonArgs`]. + pub fn call

(&mut self, name: &str, params: P) -> Result + where + P: IntoJsonArgs, + { + self.bus.call(&self.entry.device_id, name, params) + } } diff --git a/src/devices/mod.rs b/src/devices/mod.rs new file mode 100644 index 0000000..839705a --- /dev/null +++ b/src/devices/mod.rs @@ -0,0 +1 @@ +pub mod redstone; diff --git a/src/devices/redstone.rs b/src/devices/redstone.rs new file mode 100644 index 0000000..cb191d8 --- /dev/null +++ b/src/devices/redstone.rs @@ -0,0 +1,144 @@ +use crate::bus::Device; +use crate::{DeviceBus, DeviceEntry, Error, JsonValue, Result}; +use miniserde::json::Number; +use std::fmt; +use std::str::FromStr; + +/// Typed wrapper around the HLAPI redstone interface. +pub struct Redstone<'bus> { + device: Device<'bus>, +} + +impl<'bus> Redstone<'bus> { + pub const TYPE_NAME: &'static str = "redstone"; + + /// Attempt to find and wrap the first redstone-compatible device on the bus. + pub fn attach(bus: &'bus mut DeviceBus) -> Result> { + let Some(entry) = bus.find(Self::TYPE_NAME)? else { + return Ok(None); + }; + Ok(bus.device(&entry.device_id)?.map(|device| Self { device })) + } + + /// Create a typed wrapper from an existing [`Device`]. + pub fn from_device(device: Device<'bus>) -> Self { + Self { device } + } + + /// Access the underlying device entry metadata. + pub fn info(&self) -> &DeviceEntry { + self.device.info() + } + + /// Borrow the underlying dynamic device handle. + pub fn as_device(&mut self) -> &mut Device<'bus> { + &mut self.device + } + + /// Read the redstone input level for `side`. + pub fn input(&mut self, side: Side) -> Result { + let value = self.device.call("getRedstoneInput", (side.as_str(),))?; + expect_number(value, "getRedstoneInput") + } + + /// Read the currently configured output level for `side`. + pub fn output(&mut self, side: Side) -> Result { + let value = self.device.call("getRedstoneOutput", (side.as_str(),))?; + expect_number(value, "getRedstoneOutput") + } + + /// Set the output strength on `side` (0-15). + pub fn set_output_level(&mut self, side: Side, level: u8) -> Result<()> { + let clamped = level.min(15) as u64; + let _ = self + .device + .call("setRedstoneOutput", (side.as_str(), clamped))?; + Ok(()) + } + + /// Convenience for enabling (`true`) or disabling (`false`) an output. + pub fn set_output_state(&mut self, side: Side, active: bool) -> Result<()> { + self.set_output_level(side, if active { 15 } else { 0 }) + } +} + +fn expect_number(value: JsonValue, context: &str) -> Result { + match value { + JsonValue::Number(Number::F64(v)) => Ok(v), + JsonValue::Number(Number::I64(v)) => Ok(v as f64), + JsonValue::Number(Number::U64(v)) => Ok(v as f64), + other => Err(Error::Protocol(format!( + "expected numeric response from {context}, got {other:?}" + ))), + } +} + +/// Redstone interface sides used by OC2R. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Side { + Up, + Down, + North, + South, + West, + East, + Front, + Back, + Left, + Right, +} + +impl Side { + pub const fn as_str(self) -> &'static str { + match self { + Side::Up => "up", + Side::Down => "down", + Side::North => "north", + Side::South => "south", + Side::West => "west", + Side::East => "east", + Side::Front => "front", + Side::Back => "back", + Side::Left => "left", + Side::Right => "right", + } + } +} + +impl fmt::Display for Side { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for Side { + type Err = ParseSideError; + + fn from_str(value: &str) -> std::result::Result { + match value.trim().to_ascii_lowercase().as_str() { + "up" => Ok(Side::Up), + "down" => Ok(Side::Down), + "north" => Ok(Side::North), + "south" => Ok(Side::South), + "west" => Ok(Side::West), + "east" => Ok(Side::East), + "front" => Ok(Side::Front), + "back" => Ok(Side::Back), + "left" => Ok(Side::Left), + "right" => Ok(Side::Right), + _ => Err(ParseSideError), + } + } +} + +/// Error returned when parsing a [`Side`] fails. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ParseSideError; + +impl fmt::Display for ParseSideError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("invalid side") + } +} + +impl std::error::Error for ParseSideError {} diff --git a/src/lib.rs b/src/lib.rs index 16015e0..a1e186f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,28 +7,34 @@ //! //! # Quick start //! ```no_run -//! use oc2r_rust::{DeviceBus, JsonValue, Result, DEFAULT_DEVICE_PATH}; +//! use oc2r_rust::{DeviceBus, Result, DEFAULT_DEVICE_PATH}; //! //! fn main() -> Result<()> { //! let mut bus = DeviceBus::connect(DEFAULT_DEVICE_PATH)?; //! let devices = bus.list()?; //! println!("Found {} device(s)", devices.len()); //! -//! if let Some(device) = bus.device(&devices[0].device_id)? { -//! let response = device.invoke("help", &[JsonValue::Null])?; -//! println!("help(): {response:?}"); +//! if let Some(entry) = devices.first() { +//! if let Some(mut device) = bus.device(&entry.device_id)? { +//! let response = device.call("help", ())?; +//! println!("help(): {response:?}"); +//! } //! } //! Ok(()) //! } //! ``` pub mod bus; +pub mod devices; pub mod error; pub mod rpc; mod transport; +mod value; pub use bus::{Device, DeviceBus}; +pub use devices::redstone::{ParseSideError, Redstone, Side}; pub use error::{Error, Result}; pub use rpc::{DeviceEntry, JsonValue, MethodEntry, MethodParameter}; +pub use value::{IntoJsonArgs, IntoJsonValue}; pub const DEFAULT_DEVICE_PATH: &str = "/dev/hvc0"; diff --git a/src/value.rs b/src/value.rs new file mode 100644 index 0000000..22dbc46 --- /dev/null +++ b/src/value.rs @@ -0,0 +1,127 @@ +use crate::rpc::JsonValue; +use miniserde::json::Number; + +/// Convert common Rust types into `JsonValue`. +pub trait IntoJsonValue { + fn into_json(self) -> JsonValue; +} + +impl IntoJsonValue for JsonValue { + fn into_json(self) -> JsonValue { + self + } +} + +impl IntoJsonValue for &JsonValue { + fn into_json(self) -> JsonValue { + self.clone() + } +} + +impl IntoJsonValue for bool { + fn into_json(self) -> JsonValue { + JsonValue::Bool(self) + } +} + +macro_rules! impl_int { + ($($ty:ty => $variant:ident),+ $(,)?) => { + $( + impl IntoJsonValue for $ty { + fn into_json(self) -> JsonValue { + JsonValue::Number(Number::$variant(self as _)) + } + } + )+ + }; +} + +impl_int!(i8 => I64, i16 => I64, i32 => I64, i64 => I64, isize => I64); +impl_int!(u8 => U64, u16 => U64, u32 => U64, u64 => U64, usize => U64); + +impl IntoJsonValue for f32 { + fn into_json(self) -> JsonValue { + JsonValue::Number(Number::F64(self as f64)) + } +} + +impl IntoJsonValue for f64 { + fn into_json(self) -> JsonValue { + JsonValue::Number(Number::F64(self)) + } +} + +impl IntoJsonValue for String { + fn into_json(self) -> JsonValue { + JsonValue::String(self) + } +} + +impl IntoJsonValue for &str { + fn into_json(self) -> JsonValue { + JsonValue::String(self.to_owned()) + } +} + +impl IntoJsonValue for &String { + fn into_json(self) -> JsonValue { + JsonValue::String(self.clone()) + } +} + +impl IntoJsonValue for Option { + fn into_json(self) -> JsonValue { + match self { + Some(value) => value.into_json(), + None => JsonValue::Null, + } + } +} + +/// Convert heterogeneous argument lists into JSON values. +pub trait IntoJsonArgs { + fn into_args(self) -> Vec; +} + +impl IntoJsonArgs for () { + fn into_args(self) -> Vec { + Vec::new() + } +} + +macro_rules! tuple_args { + ($($name:ident),+) => { + #[allow(non_snake_case, non_camel_case_types)] + impl<$($name),+> IntoJsonArgs for ($($name,)+) + where + $($name: IntoJsonValue,)+ + { + fn into_args(self) -> Vec { + let ($($name,)+) = self; + vec![$($name.into_json(),)+] + } + } + }; +} + +tuple_args!(a); +tuple_args!(a, b); +tuple_args!(a, b, c); +tuple_args!(a, b, c, d); +tuple_args!(a, b, c, d, e); +tuple_args!(a, b, c, d, e, f); + +impl IntoJsonArgs for Vec { + fn into_args(self) -> Vec { + self.into_iter().map(IntoJsonValue::into_json).collect() + } +} + +impl IntoJsonArgs for &[T] +where + T: IntoJsonValue + Clone, +{ + fn into_args(self) -> Vec { + self.iter().cloned().map(IntoJsonValue::into_json).collect() + } +}