Skip to main content

max / mountaineer

sysop wifi: pre-scan, rescan, auto-iface, disconnect - Auto-detect wireless device from `iwctl device list` (mode=station), fall back to wlan0 if no station-mode iface is found. - Pre-scan: `iwctl station <dev> scan` + 1.5s settle before get-networks, so the cache is fresh on first invocation. - `r` rescans from the list view; UI shows "scanning..." during the settle window. - `sysop wifi disconnect` verb wired through Backend trait. Backend trait now threads `dev` through scan/list/connect/disconnect so the device discovery happens once at session start.
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-19 21:03 UTC
Commit: e9e14d16a83b95da894dad05272ff6bd2e3ae514
Parent: 54b1f0f
2 files changed, +181 insertions, -54 deletions
@@ -41,7 +41,10 @@ enum Command {
41 41 summary: bool,
42 42 },
43 43 /// Connect to a wireless network
44 - Wifi,
44 + Wifi {
45 + #[command(subcommand)]
46 + action: Option<WifiAction>,
47 + },
45 48 /// Network interface status
46 49 Net {
47 50 /// One-line summary for the yambar `net` segment
@@ -51,6 +54,12 @@ enum Command {
51 54 }
52 55
53 56 #[derive(Subcommand)]
57 + enum WifiAction {
58 + /// Disconnect from the current wireless network
59 + Disconnect,
60 + }
61 +
62 + #[derive(Subcommand)]
54 63 enum MotdAction {
55 64 /// Enable the login tip
56 65 On,
@@ -72,7 +81,10 @@ fn main() -> anyhow::Result<()> {
72 81 },
73 82 Command::Services { summary } => services::run(summary)?,
74 83 Command::Storage { summary } => storage::run(summary)?,
75 - Command::Wifi => wifi::run()?,
84 + Command::Wifi { action } => match action {
85 + None => wifi::run()?,
86 + Some(WifiAction::Disconnect) => wifi::disconnect()?,
87 + },
76 88 Command::Net { summary } => net::run(summary)?,
77 89 };
78 90 std::process::exit(code);
@@ -5,9 +5,12 @@ use ratatui::style::Style;
5 5 use ratatui::text::{Line, Span};
6 6 use ratatui::widgets::Paragraph;
7 7 use std::process::Command;
8 + use std::thread;
9 + use std::time::Duration;
8 10 use sysop_tui::{Footer, GlobalAction, classify, hint, layout, palette, selection, style};
9 11
10 - const WLAN_DEV: &str = "wlan0";
12 + const FALLBACK_DEV: &str = "wlan0";
13 + const SCAN_SETTLE: Duration = Duration::from_millis(1500);
11 14
12 15 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
13 16 pub enum Security {
@@ -44,8 +47,11 @@ pub struct Network {
44 47 }
45 48
46 49 pub trait Backend {
47 - fn list(&self) -> Result<Vec<Network>>;
48 - fn connect(&self, ssid: &str, password: Option<&str>) -> Result<()>;
50 + fn device(&self) -> Result<String>;
51 + fn scan(&self, dev: &str) -> Result<()>;
52 + fn list(&self, dev: &str) -> Result<Vec<Network>>;
53 + fn connect(&self, dev: &str, ssid: &str, password: Option<&str>) -> Result<()>;
54 + fn disconnect(&self, dev: &str) -> Result<()>;
49 55 }
50 56
51 57 fn detect() -> Box<dyn Backend> {
@@ -73,9 +79,33 @@ impl Iwd {
73 79 }
74 80
75 81 impl Backend for Iwd {
76 - fn list(&self) -> Result<Vec<Network>> {
82 + fn device(&self) -> Result<String> {
77 83 let out = Command::new("iwctl")
78 - .args(["station", WLAN_DEV, "get-networks"])
84 + .args(["device", "list"])
85 + .output()
86 + .context("iwctl device list")?;
87 + if !out.status.success() {
88 + anyhow::bail!("iwctl device list failed");
89 + }
90 + Ok(parse_device(&String::from_utf8_lossy(&out.stdout))
91 + .unwrap_or_else(|| FALLBACK_DEV.to_string()))
92 + }
93 +
94 + fn scan(&self, dev: &str) -> Result<()> {
95 + let status = Command::new("iwctl")
96 + .args(["station", dev, "scan"])
97 + .status()
98 + .context("iwctl scan")?;
99 + if !status.success() {
100 + anyhow::bail!("iwctl scan exited {status}");
101 + }
102 + thread::sleep(SCAN_SETTLE);
103 + Ok(())
104 + }
105 +
106 + fn list(&self, dev: &str) -> Result<Vec<Network>> {
107 + let out = Command::new("iwctl")
108 + .args(["station", dev, "get-networks"])
79 109 .output()
80 110 .context("iwctl get-networks")?;
81 111 if !out.status.success() {
@@ -84,18 +114,47 @@ impl Backend for Iwd {
84 114 Ok(parse_networks(&String::from_utf8_lossy(&out.stdout)))
85 115 }
86 116
87 - fn connect(&self, ssid: &str, password: Option<&str>) -> Result<()> {
117 + fn connect(&self, dev: &str, ssid: &str, password: Option<&str>) -> Result<()> {
88 118 let mut cmd = Command::new("iwctl");
89 119 if let Some(psk) = password {
90 120 cmd.arg(format!("--passphrase={psk}"));
91 121 }
92 - cmd.args(["station", WLAN_DEV, "connect", ssid]);
122 + cmd.args(["station", dev, "connect", ssid]);
93 123 let status = cmd.status().context("iwctl connect")?;
94 124 if !status.success() {
95 125 anyhow::bail!("iwctl exited {status}");
96 126 }
97 127 Ok(())
98 128 }
129 +
130 + fn disconnect(&self, dev: &str) -> Result<()> {
131 + let status = Command::new("iwctl")
132 + .args(["station", dev, "disconnect"])
133 + .status()
134 + .context("iwctl disconnect")?;
135 + if !status.success() {
136 + anyhow::bail!("iwctl disconnect exited {status}");
137 + }
138 + Ok(())
139 + }
140 + }
141 +
142 + fn parse_device(text: &str) -> Option<String> {
143 + for raw in text.lines() {
144 + let line = raw.trim();
145 + if line.is_empty() || line.contains("---") || line.contains("Name") {
146 + continue;
147 + }
148 + let parts: Vec<&str> = line.split_whitespace().collect();
149 + if parts.len() < 5 {
150 + continue;
151 + }
152 + let mode = parts[parts.len() - 1];
153 + if mode == "station" {
154 + return Some(parts[0].to_string());
155 + }
156 + }
157 + None
99 158 }
100 159
101 160 fn parse_networks(text: &str) -> Vec<Network> {
@@ -138,7 +197,15 @@ fn parse_networks(text: &str) -> Vec<Network> {
138 197 struct Mock;
139 198
140 199 impl Backend for Mock {
141 - fn list(&self) -> Result<Vec<Network>> {
200 + fn device(&self) -> Result<String> {
201 + Ok(FALLBACK_DEV.to_string())
202 + }
203 +
204 + fn scan(&self, _dev: &str) -> Result<()> {
205 + Ok(())
206 + }
207 +
208 + fn list(&self, _dev: &str) -> Result<Vec<Network>> {
142 209 Ok(vec![
143 210 net("home-net", Security::Psk, 4, true),
144 211 net("guest", Security::Open, 3, false),
@@ -148,7 +215,11 @@ impl Backend for Mock {
148 215 ])
149 216 }
150 217
151 - fn connect(&self, _ssid: &str, _password: Option<&str>) -> Result<()> {
218 + fn connect(&self, _dev: &str, _ssid: &str, _password: Option<&str>) -> Result<()> {
219 + Ok(())
220 + }
221 +
222 + fn disconnect(&self, _dev: &str) -> Result<()> {
152 223 Ok(())
153 224 }
154 225 }
@@ -177,22 +248,24 @@ enum Outcome {
177 248
178 249 pub fn run() -> Result<i32> {
179 250 let backend = detect();
180 - let networks = backend.list()?;
251 + let dev = backend.device()?;
252 + backend.scan(&dev).ok();
253 + let networks = backend.list(&dev)?;
181 254 if networks.is_empty() {
182 - eprintln!("sysop wifi: no networks visible");
183 - eprintln!(" try: iwctl station {WLAN_DEV} scan");
255 + eprintln!("sysop wifi: no networks visible on {dev}");
256 + eprintln!(" try: iwctl station {dev} scan");
184 257 return Ok(1);
185 258 }
186 259
187 260 let mut terminal = ratatui::init();
188 - let result = event_loop(&mut terminal, &networks);
261 + let result = event_loop(&mut terminal, backend.as_ref(), &dev, networks);
189 262 ratatui::restore();
190 263
191 264 match result? {
192 265 Outcome::Quit => Ok(0),
193 266 Outcome::Connect { ssid, password } => {
194 267 println!("connecting to {ssid}...");
195 - match backend.connect(&ssid, password.as_deref()) {
268 + match backend.connect(&dev, &ssid, password.as_deref()) {
196 269 Ok(()) => {
197 270 println!("connected");
198 271 Ok(0)
@@ -206,11 +279,42 @@ pub fn run() -> Result<i32> {
206 279 }
207 280 }
208 281
209 - fn event_loop(terminal: &mut ratatui::DefaultTerminal, networks: &[Network]) -> Result<Outcome> {
282 + pub fn disconnect() -> Result<i32> {
283 + let backend = detect();
284 + let dev = backend.device()?;
285 + match backend.disconnect(&dev) {
286 + Ok(()) => {
287 + println!("disconnected {dev}");
288 + Ok(0)
289 + }
290 + Err(e) => {
291 + eprintln!("disconnect failed: {e}");
292 + Ok(1)
293 + }
294 + }
295 + }
296 +
297 + fn event_loop(
298 + terminal: &mut ratatui::DefaultTerminal,
299 + backend: &dyn Backend,
300 + dev: &str,
301 + initial: Vec<Network>,
302 + ) -> Result<Outcome> {
303 + let mut networks = initial;
210 304 let mut selected: usize = 0;
211 305 let mut view = View::List;
306 + let mut scanning = false;
212 307 loop {
213 - terminal.draw(|frame| draw(frame, networks, selected, &view))?;
308 + terminal.draw(|frame| draw(frame, &networks, selected, &view, scanning))?;
309 +
310 + if scanning {
311 + scanning = false;
312 + backend.scan(dev).ok();
313 + networks = backend.list(dev).unwrap_or(networks);
314 + selected = selected.min(networks.len().saturating_sub(1));
315 + continue;
316 + }
317 +
214 318 let Event::Key(key) = event::read()? else {
215 319 continue;
216 320 };
@@ -225,7 +329,10 @@ fn event_loop(terminal: &mut ratatui::DefaultTerminal, networks: &[Network]) ->
225 329 KeyCode::Char('k') | KeyCode::Up => {
226 330 selected = selected.saturating_sub(1);
227 331 }
228 - KeyCode::Enter => match networks[selected].security {
332 + KeyCode::Char('r') => {
333 + scanning = true;
334 + }
335 + KeyCode::Enter if !networks.is_empty() => match networks[selected].security {
229 336 Security::Open => {
230 337 return Ok(Outcome::Connect {
231 338 ssid: networks[selected].ssid.clone(),
@@ -269,15 +376,16 @@ fn event_loop(terminal: &mut ratatui::DefaultTerminal, networks: &[Network]) ->
269 376 }
270 377 }
271 378
272 - fn draw(frame: &mut Frame, networks: &[Network], selected: usize, view: &View) {
379 + fn draw(frame: &mut Frame, networks: &[Network], selected: usize, view: &View, scanning: bool) {
273 380 let [body, foot] = layout::split(frame.area());
274 381 match view {
275 382 View::List => {
276 - frame.render_widget(list_widget(networks, selected), body);
383 + frame.render_widget(list_widget(networks, selected, scanning), body);
277 384 frame.render_widget(
278 385 &Footer::new([
279 386 hint("j/k", "move"),
280 387 hint("Enter", "connect"),
388 + hint("r", "rescan"),
281 389 hint("q", "quit"),
282 390 ]),
283 391 foot,
@@ -297,40 +405,47 @@ fn draw(frame: &mut Frame, networks: &[Network], selected: usize, view: &View) {
297 405 }
298 406 }
299 407
300 - fn list_widget(networks: &[Network], selected: usize) -> Paragraph<'_> {
408 + fn list_widget(networks: &[Network], selected: usize, scanning: bool) -> Paragraph<'_> {
301 409 let name_w = networks.iter().map(|n| n.ssid.len()).max().unwrap_or(0).max(8);
302 - let lines: Vec<Line> = networks
303 - .iter()
304 - .enumerate()
305 - .map(|(i, n)| {
306 - let is_sel = i == selected;
307 - let marker = if is_sel { selection::MARKER } else { " " };
308 - let known_mark = if n.known { "*" } else { " " };
309 - let signal = format!(
310 - "{:<4}",
311 - "▂▄▆█".chars().take(n.signal_bars as usize).collect::<String>()
312 - );
313 - let security_span = match n.security {
314 - Security::Enterprise => style::inactive(n.security.label()),
315 - _ => style::dim(n.security.label()),
316 - };
317 - let line = Line::from(vec![
318 - Span::raw(format!(" {marker} ")),
319 - style::dim(known_mark),
320 - Span::raw(" "),
321 - Span::raw(format!("{:<name_w$}", n.ssid)),
322 - Span::raw(" "),
323 - Span::raw(signal),
324 - Span::raw(" "),
325 - security_span,
326 - ]);
327 - if is_sel {
328 - line.style(selection::selected_style())
329 - } else {
330 - line
331 - }
332 - })
333 - .collect();
410 + let mut lines: Vec<Line> = Vec::with_capacity(networks.len() + 2);
411 +
412 + if scanning {
413 + lines.push(Line::from(vec![
414 + Span::raw(" "),
415 + style::dim("scanning..."),
416 + ]));
417 + lines.push(Line::from(""));
418 + }
419 +
420 + for (i, n) in networks.iter().enumerate() {
421 + let is_sel = i == selected;
422 + let marker = if is_sel { selection::MARKER } else { " " };
423 + let known_mark = if n.known { "*" } else { " " };
424 + let signal = format!(
425 + "{:<4}",
426 + "▂▄▆█".chars().take(n.signal_bars as usize).collect::<String>()
427 + );
428 + let security_span = match n.security {
429 + Security::Enterprise => style::inactive(n.security.label()),
430 + _ => style::dim(n.security.label()),
431 + };
432 + let line = Line::from(vec![
433 + Span::raw(format!(" {marker} ")),
434 + style::dim(known_mark),
435 + Span::raw(" "),
436 + Span::raw(format!("{:<name_w$}", n.ssid)),
437 + Span::raw(" "),
438 + Span::raw(signal),
439 + Span::raw(" "),
440 + security_span,
441 + ]);
442 + let line = if is_sel {
443 + line.style(selection::selected_style())
444 + } else {
445 + line
446 + };
447 + lines.push(line);
448 + }
334 449 Paragraph::new(lines).style(Style::default().bg(palette::BG).fg(palette::FG))
335 450 }
336 451