diff --git a/lib/native.ex b/lib/native.ex index cf2cdab..586e721 100644 --- a/lib/native.ex +++ b/lib/native.ex @@ -81,6 +81,28 @@ defmodule RustlerBtleplug.Native do @spec unsubscribe(peripheral(), uuid(), number()) :: {:ok, peripheral()} | {:error, term()} def unsubscribe(_peripheral, _characteristic, _timeout \\ @default_timeout), do: error() + ## ✅ Read & Write Characteristics + @doc """ + Write data to a characteristic. + + Returns the peripheral reference. The operation is asynchronous. + """ + @spec write_characteristic(peripheral(), uuid(), binary(), number()) :: + {:ok, peripheral()} | {:error, term()} + def write_characteristic(_peripheral, _characteristic, _data, _timeout \\ @default_timeout), + do: error() + + @doc """ + Read data from a characteristic. + + Returns the peripheral reference. The read data will be sent as a message: + `{:btleplug_characteristic_read, uuid, data}` + """ + @spec read_characteristic(peripheral(), uuid(), number()) :: + {:ok, peripheral()} | {:error, term()} + def read_characteristic(_peripheral, _characteristic, _timeout \\ @default_timeout), + do: error() + ## ✅ Adapter State Queries (Graph & Mindmap) @doc """ Retrieve the adapter state as a **GraphViz** or **Mermaid mindmap**. diff --git a/native/btleplug_client/src/atoms.rs b/native/btleplug_client/src/atoms.rs index be61cf8..85b06b3 100644 --- a/native/btleplug_client/src/atoms.rs +++ b/native/btleplug_client/src/atoms.rs @@ -30,4 +30,5 @@ rustler::atoms! { btleplug_services_advertisement, btleplug_characteristic_value_changed, + btleplug_characteristic_read, } diff --git a/native/btleplug_client/src/peripheral.rs b/native/btleplug_client/src/peripheral.rs index 18e0988..6f6a000 100644 --- a/native/btleplug_client/src/peripheral.rs +++ b/native/btleplug_client/src/peripheral.rs @@ -486,3 +486,240 @@ pub fn unsubscribe( Ok(resource) } + +#[rustler::nif] +pub fn write_characteristic( + env: Env, + resource: ResourceArc, + characteristic_uuid: String, + data: Vec, + timeout_ms: u64, +) -> Result, RustlerError> { + let peripheral_arc = resource.0.clone(); + let env_pid = env.pid(); + + RUNTIME.spawn(async move { + let (peripheral, state, pid) = { + let state_guard = peripheral_arc.lock().unwrap(); + ( + state_guard.peripheral.clone(), + state_guard.state, + state_guard.pid, + ) + }; + + info!( + "✍️ Writing to Peripheral: {:?}, characteristic: {}, caller pid: {:?}, state pid: {:?}", + peripheral.id(), + characteristic_uuid, + env_pid.as_c_arg(), + pid.as_c_arg() + ); + + if state != PeripheralStateEnum::ServicesDiscovered { + warn!("⚠️ Services not yet discovered. Manually triggering discovery..."); + if let Err(e) = timeout( + Duration::from_millis(timeout_ms), + peripheral.discover_services(), + ) + .await + { + warn!("❌ Service discovery failed: {:?}", e); + return; + } + + RUNTIME.spawn({ + let peripheral_arc_clone = peripheral_arc.clone(); + async move { + if !discover_services_internal(&peripheral_arc_clone, timeout_ms).await { + warn!("⚠️ No services discovered, but proceeding with write."); + } + } + }); + } + + info!("🔍 Waiting 2s before checking characteristics..."); + tokio::time::sleep(Duration::from_millis(2000)).await; + + let characteristics = peripheral.characteristics(); + let characteristic = characteristics + .iter() + .find(|c| c.uuid.to_string() == characteristic_uuid) + .cloned(); + + match characteristic { + Some(char) => { + debug!("✍️ Writing to characteristic: {:?}", char.uuid); + info!( + "✍️ Found characteristic: {:?}, Properties: {:?}, Data length: {}", + char.uuid, + char.properties, + data.len() + ); + + if !char.properties.contains(CharPropFlags::WRITE) + && !char.properties.contains(CharPropFlags::WRITE_WITHOUT_RESPONSE) + { + warn!( + "⚠️ Characteristic {:?} does NOT support write operations!", + char.uuid + ); + return; + } + + let write_type = if char.properties.contains(CharPropFlags::WRITE) { + btleplug::api::WriteType::WithResponse + } else { + btleplug::api::WriteType::WithoutResponse + }; + + match timeout( + Duration::from_millis(timeout_ms), + peripheral.write(&char, &data, write_type), + ) + .await + { + Ok(Ok(_)) => info!( + "✅ Successfully wrote {} bytes to characteristic: {:?}", + data.len(), + char.uuid + ), + Ok(Err(e)) => { + warn!("❌ Failed to write to {:?}: {:?}", char.uuid, e); + } + Err(_) => { + warn!("❌ Write timeout for {:?}", char.uuid); + } + } + } + None => warn!( + "❌ Characteristic with UUID {} not found! Available UUIDs: {:?}", + characteristic_uuid, + characteristics + .iter() + .map(|c| c.uuid.to_string()) + .collect::>() + ), + } + }); + + Ok(resource) +} + +#[rustler::nif] +pub fn read_characteristic( + env: Env, + resource: ResourceArc, + characteristic_uuid: String, + timeout_ms: u64, +) -> Result, RustlerError> { + let peripheral_arc = resource.0.clone(); + let env_pid = env.pid(); + + RUNTIME.spawn(async move { + let (peripheral, state, pid) = { + let state_guard = peripheral_arc.lock().unwrap(); + ( + state_guard.peripheral.clone(), + state_guard.state, + state_guard.pid, + ) + }; + + info!( + "📖 Reading from Peripheral: {:?}, characteristic: {}, caller pid: {:?}, state pid: {:?}", + peripheral.id(), + characteristic_uuid, + env_pid.as_c_arg(), + pid.as_c_arg() + ); + + if state != PeripheralStateEnum::ServicesDiscovered { + warn!("⚠️ Services not yet discovered. Manually triggering discovery..."); + if let Err(e) = timeout( + Duration::from_millis(timeout_ms), + peripheral.discover_services(), + ) + .await + { + warn!("❌ Service discovery failed: {:?}", e); + return; + } + + RUNTIME.spawn({ + let peripheral_arc_clone = peripheral_arc.clone(); + async move { + if !discover_services_internal(&peripheral_arc_clone, timeout_ms).await { + warn!("⚠️ No services discovered, but proceeding with read."); + } + } + }); + } + + info!("🔍 Waiting 2s before checking characteristics..."); + tokio::time::sleep(Duration::from_millis(2000)).await; + + let characteristics = peripheral.characteristics(); + let characteristic = characteristics + .iter() + .find(|c| c.uuid.to_string() == characteristic_uuid) + .cloned(); + + match characteristic { + Some(char) => { + debug!("📖 Reading from characteristic: {:?}", char.uuid); + info!( + "📖 Found characteristic: {:?}, Properties: {:?}", + char.uuid, char.properties + ); + + if !char.properties.contains(CharPropFlags::READ) { + warn!( + "⚠️ Characteristic {:?} does NOT support read operations!", + char.uuid + ); + return; + } + + match timeout(Duration::from_millis(timeout_ms), peripheral.read(&char)).await { + Ok(Ok(data)) => { + info!( + "✅ Successfully read {} bytes from characteristic: {:?}", + data.len(), + char.uuid + ); + + // Send the read data back to Elixir via a message + let mut owned_env = OwnedEnv::new(); + owned_env.send_and_clear(&pid, |env| { + let data_term = data.encode(env); + let uuid_term = char.uuid.to_string().encode(env); + ( + atoms::btleplug_characteristic_read().encode(env), + uuid_term, + data_term, + ) + .encode(env) + }); + } + Ok(Err(e)) => { + warn!("❌ Failed to read from {:?}: {:?}", char.uuid, e); + } + Err(_) => { + warn!("❌ Read timeout for {:?}", char.uuid); + } + } + } + None => warn!( + "❌ Characteristic with UUID {} not found! Available UUIDs: {:?}", + characteristic_uuid, + characteristics + .iter() + .map(|c| c.uuid.to_string()) + .collect::>() + ), + } + }); + + Ok(resource) +} diff --git a/test/rustler_btleplug_native_write_read_test.exs b/test/rustler_btleplug_native_write_read_test.exs new file mode 100644 index 0000000..3157344 --- /dev/null +++ b/test/rustler_btleplug_native_write_read_test.exs @@ -0,0 +1,204 @@ +defmodule RustlerBtleplug.NativeWriteReadTest do + use ExUnit.Case, async: false + alias RustlerBtleplug.Native + + # These should be set to match actual test device characteristics + @test_peripheral_name "toio" + @test_write_characteristic_uuid "10b20102-5b3b-4571-9508-cf3efcd7bbae" + @test_read_characteristic_uuid "10b20101-5b3b-4571-9508-cf3efcd7bbae" + @test_timeout 10_000 + + describe "write_characteristic/4" do + @tag :integration + test "writes data to a writable characteristic" do + central_resource = + Native.create_central() + |> Native.start_scan() + + assert is_reference(central_resource) + assert_receive {:btleplug_scan_started, _msg}, 2000 + + Process.sleep(1000) + + receive do + {:btleplug_peripheral_discovered, peripheral_id, _props} -> + peripheral_resource = + central_resource + |> Native.find_peripheral_by_name(@test_peripheral_name) + |> Native.connect() + + assert is_reference(peripheral_resource) + + assert_receive {:btleplug_peripheral_connected, _msg}, + @test_timeout, + "No :btleplug_peripheral_connected received" + + assert_receive {:btleplug_peripheral_updated, _msg, properties}, + @test_timeout, + "No :btleplug_peripheral_updated received" + + assert is_map(properties) + + # Write motor stop command: [0x01, 0x01, 0x00, 0x00, 0x02, 0x00, 0x00] + write_result = + Native.write_characteristic( + peripheral_resource, + @test_write_characteristic_uuid, + [0x01, 0x01, 0x00, 0x00, 0x02, 0x00, 0x00], + @test_timeout + ) + + assert is_reference(write_result) + + # Cleanup + Native.disconnect(peripheral_resource) + + after + @test_timeout * 2 -> flunk("Did not receive :btleplug_peripheral_discovered message") + end + end + + @tag :integration + test "handles write to non-writable characteristic gracefully" do + central_resource = + Native.create_central() + |> Native.start_scan() + + assert is_reference(central_resource) + assert_receive {:btleplug_scan_started, _msg}, 2000 + + Process.sleep(1000) + + receive do + {:btleplug_peripheral_discovered, _peripheral_id, _props} -> + peripheral_resource = + central_resource + |> Native.find_peripheral_by_name(@test_peripheral_name) + |> Native.connect() + + assert is_reference(peripheral_resource) + + assert_receive {:btleplug_peripheral_connected, _msg}, + @test_timeout, + "No :btleplug_peripheral_connected received" + + # Attempt to write to a read-only characteristic (should log warning but not crash) + write_result = + Native.write_characteristic( + peripheral_resource, + @test_read_characteristic_uuid, + [0x00], + @test_timeout + ) + + assert is_reference(write_result) + + # Cleanup + Native.disconnect(peripheral_resource) + + after + @test_timeout * 2 -> flunk("Did not receive :btleplug_peripheral_discovered message") + end + end + end + + describe "read_characteristic/3" do + @tag :integration + test "reads data from a readable characteristic" do + central_resource = + Native.create_central() + |> Native.start_scan() + + assert is_reference(central_resource) + assert_receive {:btleplug_scan_started, _msg}, 2000 + + Process.sleep(1000) + + receive do + {:btleplug_peripheral_discovered, _peripheral_id, _props} -> + peripheral_resource = + central_resource + |> Native.find_peripheral_by_name(@test_peripheral_name) + |> Native.connect() + + assert is_reference(peripheral_resource) + + assert_receive {:btleplug_peripheral_connected, _msg}, + @test_timeout, + "No :btleplug_peripheral_connected received" + + assert_receive {:btleplug_peripheral_updated, _msg, properties}, + @test_timeout, + "No :btleplug_peripheral_updated received" + + assert is_map(properties) + + # Read from position ID characteristic + read_result = + Native.read_characteristic( + peripheral_resource, + @test_read_characteristic_uuid, + @test_timeout + ) + + assert is_reference(read_result) + + # Should receive read data as message + assert_receive {:btleplug_characteristic_read, uuid, data}, + @test_timeout, + "No :btleplug_characteristic_read received" + + assert is_binary(uuid) + assert is_binary(data) + + # Cleanup + Native.disconnect(peripheral_resource) + + after + @test_timeout * 2 -> flunk("Did not receive :btleplug_peripheral_discovered message") + end + end + + @tag :integration + test "handles read from non-readable characteristic gracefully" do + central_resource = + Native.create_central() + |> Native.start_scan() + + assert is_reference(central_resource) + assert_receive {:btleplug_scan_started, _msg}, 2000 + + Process.sleep(1000) + + receive do + {:btleplug_peripheral_discovered, _peripheral_id, _props} -> + peripheral_resource = + central_resource + |> Native.find_peripheral_by_name(@test_peripheral_name) + |> Native.connect() + + assert is_reference(peripheral_resource) + + assert_receive {:btleplug_peripheral_connected, _msg}, + @test_timeout, + "No :btleplug_peripheral_connected received" + + # Attempt to read from a write-only characteristic (should log warning but not crash) + read_result = + Native.read_characteristic( + peripheral_resource, + @test_write_characteristic_uuid, + @test_timeout + ) + + assert is_reference(read_result) + + # Cleanup + Native.disconnect(peripheral_resource) + + after + @test_timeout * 2 -> flunk("Did not receive :btleplug_peripheral_discovered message") + end + end + end +end