Redstone device

This commit is contained in:
2025-10-29 18:03:54 +01:00
parent 4268c97d19
commit 8c50eb0f84
9 changed files with 426 additions and 20 deletions

View File

@@ -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/<triple>/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

View File

@@ -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(())
}

View File

@@ -1,3 +1,5 @@
// Build with: cross build --release --example list-devices
use oc2r_rust::{DEFAULT_DEVICE_PATH, DeviceBus, Result};
fn main() -> Result<()> {

62
examples/redstone.rs Normal file
View File

@@ -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::<u8>() {
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 <side> [value]");
eprintln!(" side : up|down|north|south|west|east|front|back|left|right");
eprintln!(" value : 0-15, true/on/yes, false/off/no");
}

View File

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

1
src/devices/mod.rs Normal file
View File

@@ -0,0 +1 @@
pub mod redstone;

144
src/devices/redstone.rs Normal file
View File

@@ -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<Option<Self>> {
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<f64> {
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<f64> {
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<f64> {
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<Self, Self::Err> {
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 {}

View File

@@ -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";

127
src/value.rs Normal file
View File

@@ -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<T: IntoJsonValue> IntoJsonValue for Option<T> {
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<JsonValue>;
}
impl IntoJsonArgs for () {
fn into_args(self) -> Vec<JsonValue> {
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<JsonValue> {
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<T: IntoJsonValue> IntoJsonArgs for Vec<T> {
fn into_args(self) -> Vec<JsonValue> {
self.into_iter().map(IntoJsonValue::into_json).collect()
}
}
impl<T> IntoJsonArgs for &[T]
where
T: IntoJsonValue + Clone,
{
fn into_args(self) -> Vec<JsonValue> {
self.iter().cloned().map(IntoJsonValue::into_json).collect()
}
}