Skip to content

Commit ba83843

Browse files
hackgnarclaude
andcommitted
add connectable detection to scan output and reorder enumerate columns
Infer whether each discovered BLE device is connectable from its advertising flags and service UUIDs, and surface it as a color-coded "Conn" column in the scan table. Also swap the ASCII and Data columns in the enumerate table so ASCII sits next to Properties before the raw hex dump. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3b8fa97 commit ba83843

2 files changed

Lines changed: 72 additions & 34 deletions

File tree

src/output.rs

Lines changed: 44 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -371,38 +371,38 @@ pub fn render_enumerate_table(rows: &[EnumRow]) -> String {
371371

372372
// Fixed columns: Handles, Service > Characteristics, Properties
373373
// These size to content. Data and ASCII get the remaining space.
374-
let headers = ["Handles", "Service > Characteristics", "Properties", "Data", "ASCII"];
374+
let headers = ["Handles", "Service > Characteristics", "Properties", "ASCII", "Data"];
375375
let ncols = 5;
376376

377377
// Measure fixed column widths from content
378378
let mut w_handles = headers[0].len();
379379
let mut w_desc = headers[1].len();
380380
let mut w_props = headers[2].len();
381-
let mut max_data = headers[3].len();
382-
let mut max_ascii = headers[4].len();
381+
let mut max_ascii = headers[3].len();
382+
let mut max_data = headers[4].len();
383383

384384
for row in rows {
385385
w_handles = w_handles.max(visible_len(&row.handles));
386386
w_desc = w_desc.max(visible_len(&row.description));
387387
w_props = w_props.max(visible_len(&row.properties));
388-
max_data = max_data.max(row.data.len());
389388
max_ascii = max_ascii.max(row.ascii.len());
389+
max_data = max_data.max(row.data.len());
390390
}
391391

392392
// Calculate overhead: ncols borders + padding (2 per col) + 1 trailing border
393393
// "│ col │ col │ col │ col │ col │"
394394
let overhead = ncols + 1 + ncols * 2; // 6 borders + 10 padding = 16
395395
let fixed_used = w_handles + w_desc + w_props + overhead;
396396

397-
// Remaining space for data + ascii
397+
// Remaining space for ascii + data
398398
let remaining = tw.saturating_sub(fixed_used);
399399

400400
// Split remaining: give ASCII ~1/4 (it's compact), Data ~3/4
401401
// But never less than header width, and never more than content needs
402-
let w_ascii = max_ascii.min(remaining / 4).max(headers[4].len()).max(5);
403-
let w_data = remaining.saturating_sub(w_ascii).max(headers[3].len()).max(8);
402+
let w_ascii = max_ascii.min(remaining / 4).max(headers[3].len()).max(5);
403+
let w_data = remaining.saturating_sub(w_ascii).max(headers[4].len()).max(8);
404404

405-
let widths = [w_handles, w_desc, w_props, w_data, w_ascii];
405+
let widths = [w_handles, w_desc, w_props, w_ascii, w_data];
406406

407407
let mut out = String::new();
408408
let b = ctp("─", SURFACE1);
@@ -463,9 +463,9 @@ pub fn render_enumerate_table(rows: &[EnumRow]) -> String {
463463

464464
let c_props = colorize_props(&row.properties);
465465

466-
// Wrap data and ascii into lines that fit their column widths
467-
let data_lines = wrap_plain_text(&row.data, w_data);
466+
// Wrap ascii and data into lines that fit their column widths
468467
let ascii_lines = wrap_plain_text(&row.ascii, w_ascii);
468+
let data_lines = wrap_plain_text(&row.data, w_data);
469469
let num_lines = data_lines.len().max(ascii_lines.len()).max(1);
470470

471471
for line_idx in 0..num_lines {
@@ -507,20 +507,6 @@ pub fn render_enumerate_table(rows: &[EnumRow]) -> String {
507507
out.push(' ');
508508
out.push_str(&v.to_string());
509509

510-
// Data
511-
out.push(' ');
512-
let data_chunk = data_lines.get(line_idx).map(|s| s.as_str()).unwrap_or("");
513-
let c_data = if !data_chunk.is_empty() {
514-
ctp(data_chunk, FLAMINGO).to_string()
515-
} else {
516-
String::new()
517-
};
518-
let data_vlen = visible_len(&c_data);
519-
out.push_str(&c_data);
520-
out.push_str(&" ".repeat(w_data.saturating_sub(data_vlen)));
521-
out.push(' ');
522-
out.push_str(&v.to_string());
523-
524510
// ASCII
525511
out.push(' ');
526512
let ascii_chunk = ascii_lines.get(line_idx).map(|s| s.as_str()).unwrap_or("");
@@ -535,6 +521,20 @@ pub fn render_enumerate_table(rows: &[EnumRow]) -> String {
535521
out.push(' ');
536522
out.push_str(&v.to_string());
537523

524+
// Data
525+
out.push(' ');
526+
let data_chunk = data_lines.get(line_idx).map(|s| s.as_str()).unwrap_or("");
527+
let c_data = if !data_chunk.is_empty() {
528+
ctp(data_chunk, FLAMINGO).to_string()
529+
} else {
530+
String::new()
531+
};
532+
let data_vlen = visible_len(&c_data);
533+
out.push_str(&c_data);
534+
out.push_str(&" ".repeat(w_data.saturating_sub(data_vlen)));
535+
out.push(' ');
536+
out.push_str(&v.to_string());
537+
538538
out.push('\n');
539539
}
540540
}
@@ -622,7 +622,7 @@ pub fn enum_separator_row() -> EnumRow {
622622
/// Render a scan results table with Catppuccin-styled box-drawing
623623
pub fn render_scan_table(devices: &[crate::scan::ScannedDevice]) -> String {
624624
let tw = term_width();
625-
let headers = ["Address", "Type", "RSSI", "Name"];
625+
let headers = ["Address", "Type", "RSSI", "Conn", "Name"];
626626
let ncols = headers.len();
627627

628628
// Measure column widths from content
@@ -633,15 +633,16 @@ pub fn render_scan_table(devices: &[crate::scan::ScannedDevice]) -> String {
633633
w[1] = w[1].max(type_str.len());
634634
let rssi_str = dev.rssi.map(|r| format!("{} dBm", r)).unwrap_or_else(|| "n/a".into());
635635
w[2] = w[2].max(rssi_str.len());
636+
// w[3] = "Conn" column, fixed width from header is sufficient ("Yes"/"No"/"?")
636637
let name = dev.name.as_deref().unwrap_or("");
637-
w[3] = w[3].max(name.len());
638+
w[4] = w[4].max(name.len());
638639
}
639640

640641
// Cap name column to fit terminal
641642
let overhead = ncols + 1 + ncols * 2; // borders + padding
642-
let fixed = w[0] + w[1] + w[2] + overhead;
643-
let max_name = tw.saturating_sub(fixed).max(headers[3].len()).max(8);
644-
w[3] = w[3].min(max_name);
643+
let fixed = w[0] + w[1] + w[2] + w[3] + overhead;
644+
let max_name = tw.saturating_sub(fixed).max(headers[4].len()).max(8);
645+
w[4] = w[4].min(max_name);
645646

646647
let mut out = String::new();
647648
let b = ctp("─", SURFACE1);
@@ -693,15 +694,20 @@ pub fn render_scan_table(devices: &[crate::scan::ScannedDevice]) -> String {
693694
let addr_str = format!("{}", dev.address);
694695
let type_str = format_addr_type(dev.addr_type);
695696
let rssi_str = dev.rssi.map(|r| format!("{} dBm", r)).unwrap_or_else(|| "n/a".into());
697+
let conn_str = match dev.connectable {
698+
Some(true) => "Yes",
699+
Some(false) => "No",
700+
None => "?",
701+
};
696702
let name = dev.name.as_deref().unwrap_or("");
697703
// Truncate name if too long
698-
let name_display = if name.len() > w[3] {
699-
&name[..w[3]]
704+
let name_display = if name.len() > w[4] {
705+
&name[..w[4]]
700706
} else {
701707
name
702708
};
703709

704-
let fields: Vec<String> = vec![addr_str, type_str, rssi_str, name_display.to_string()];
710+
let fields: Vec<String> = vec![addr_str, type_str, rssi_str, conn_str.to_string(), name_display.to_string()];
705711

706712
out.push_str(&v.to_string());
707713
for (i, field) in fields.iter().enumerate() {
@@ -710,7 +716,12 @@ pub fn render_scan_table(devices: &[crate::scan::ScannedDevice]) -> String {
710716
0 => ctp_bold(field, ROSEWATER).to_string(),
711717
1 => ctp(field, LAVENDER).to_string(),
712718
2 => colorize_rssi(field, dev.rssi),
713-
3 => {
719+
3 => match dev.connectable {
720+
Some(true) => ctp_bold(field, GREEN).to_string(),
721+
Some(false) => ctp(field, RED).to_string(),
722+
None => ctp(field, OVERLAY0).to_string(),
723+
},
724+
4 => {
714725
if field.is_empty() {
715726
ctp("(unknown)", OVERLAY0).to_string()
716727
} else {

src/scan.rs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,39 @@
11
use std::collections::BTreeMap;
22

3-
use bluer::{Adapter, AdapterEvent, Address, AddressType, Session};
3+
use bluer::{Adapter, AdapterEvent, Address, AddressType, Device, Session};
44
use futures::StreamExt;
55

66
use crate::error::GrattError;
77
use crate::output;
88

9+
/// Determine if a device is likely connectable based on advertising flags and service UUIDs.
10+
async fn infer_connectable(dev: &Device) -> Option<bool> {
11+
// AD Flags bit 0 = LE Limited Discoverable, bit 1 = LE General Discoverable
12+
// Devices advertising with discoverable flags are typically connectable.
13+
if let Ok(Some(flags)) = dev.advertising_flags().await {
14+
if let Some(&byte) = flags.first() {
15+
if byte & 0x03 != 0 {
16+
return Some(true);
17+
}
18+
}
19+
}
20+
// Devices advertising service UUIDs are typically GATT servers (connectable).
21+
if let Ok(Some(uuids)) = dev.uuids().await {
22+
if !uuids.is_empty() {
23+
return Some(true);
24+
}
25+
}
26+
None
27+
}
28+
929
/// Information about a discovered BLE device
1030
#[derive(Debug, Clone)]
1131
pub struct ScannedDevice {
1232
pub address: Address,
1333
pub addr_type: Option<AddressType>,
1434
pub name: Option<String>,
1535
pub rssi: Option<i16>,
36+
pub connectable: Option<bool>,
1637
}
1738

1839
/// Perform a BLE device scan for the given duration (seconds).
@@ -54,6 +75,7 @@ pub async fn scan(adapter_name: &str, duration_secs: u64) -> Result<Vec<ScannedD
5475
let name = dev.name().await.ok().flatten();
5576
let rssi = dev.rssi().await.ok().flatten();
5677
let addr_type = dev.address_type().await.ok();
78+
let connectable = infer_connectable(&dev).await;
5779

5880
let display_name = name.as_deref().unwrap_or("(unknown)");
5981
eprintln!("{} {} RSSI: {}",
@@ -67,6 +89,7 @@ pub async fn scan(adapter_name: &str, duration_secs: u64) -> Result<Vec<ScannedD
6789
addr_type,
6890
name,
6991
rssi,
92+
connectable,
7093
});
7194
}
7295
}
@@ -103,12 +126,14 @@ async fn populate_known_devices(
103126
let name = dev.name().await.ok().flatten();
104127
let rssi = dev.rssi().await.ok().flatten();
105128
let addr_type = dev.address_type().await.ok();
129+
let connectable = infer_connectable(&dev).await;
106130

107131
devices.insert(addr, ScannedDevice {
108132
address: addr,
109133
addr_type,
110134
name,
111135
rssi,
136+
connectable,
112137
});
113138
}
114139
}
@@ -150,6 +175,7 @@ pub async fn scan_interactive(adapter_name: &str, duration_secs: u64) -> Result<
150175
let name = dev.name().await.ok().flatten();
151176
let rssi = dev.rssi().await.ok().flatten();
152177
let addr_type = dev.address_type().await.ok();
178+
let connectable = infer_connectable(&dev).await;
153179

154180
let display_name = name.as_deref().unwrap_or("(unknown)");
155181
live_output.push_str(&format!(
@@ -164,6 +190,7 @@ pub async fn scan_interactive(adapter_name: &str, duration_secs: u64) -> Result<
164190
addr_type,
165191
name,
166192
rssi,
193+
connectable,
167194
});
168195
}
169196
}

0 commit comments

Comments
 (0)