what a plan! what an impl! this is ahh

This commit is contained in:
lda
2026-04-11 15:25:18 +07:00 Unverified
parent a36e9d058d
commit e2feaeab8c
43 changed files with 1090 additions and 1629 deletions
+10 -30
View File
@@ -2,20 +2,17 @@
pub mod table;
use std::net::{IpAddr, SocketAddr};
use std::net::IpAddr;
use anyhow::Result;
use clap::{ArgAction, Args, Parser, Subcommand};
use tracing::{debug, info};
use tracing::debug;
use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt};
use wakey_core::{DeviceFilters, DeviceQuery, InterfaceSummary, WakeResult};
#[derive(Parser)]
#[command(name = "wakey")]
#[command(version, about = "CLI and temporary HTTP adapter for Wakey")]
#[command(
long_about = "Wakey can run as a local/operator CLI or serve the legacy HTTP/static interface during the migration to a service-first architecture."
)]
#[command(version, about = "Operator CLI for Wakey service actions")]
pub struct Cli {
/// Increase log verbosity. Use `-v` for debug and `-vv` for trace.
#[arg(short = 'v', long = "verbose", action = ArgAction::Count, global = true)]
@@ -27,8 +24,6 @@ pub struct Cli {
#[derive(Subcommand)]
pub enum Command {
/// Serve the temporary legacy HTTP/static app.
Http(HttpArgs),
/// Show device status rows from neighbor/device data.
Status(StatusArgs),
/// Show DHCP leases, optionally enriched with current neighbor state.
@@ -39,16 +34,6 @@ pub enum Command {
Devs(DevsArgs),
}
#[derive(Args)]
pub struct HttpArgs {
/// Host address to bind the HTTP server to.
#[arg(long, default_value = "::")]
pub host: IpAddr,
/// TCP port to bind the HTTP server to.
#[arg(long, default_value_t = 12012)]
pub port: u16,
}
#[derive(Args)]
pub struct LeasesArgs {
/// Include best-known current neighbor state for each lease IP.
@@ -143,9 +128,9 @@ pub fn init_tracing(verbose: u8) {
pub fn default_filter_for_verbosity(verbose: u8) -> &'static str {
match verbose {
0 => "wakey=info,tower_http=info",
1 => "wakey=debug,tower_http=debug",
_ => "wakey=trace,tower_http=trace",
0 => "wakey=info",
1 => "wakey=debug",
_ => "wakey=trace",
}
}
@@ -153,11 +138,6 @@ pub async fn run(cli: Cli) -> Result<()> {
init_tracing(cli.verbose);
match cli.command {
Command::Http(args) => {
let addr = SocketAddr::new(args.host, args.port);
info!(%addr, "dispatching http command");
wakey::serve_http_from_current_exe(addr).await?;
}
Command::Status(args) => {
let as_json = args.json;
let query = status_args_to_query(args);
@@ -357,9 +337,9 @@ mod tests {
#[test]
fn verbosity_maps_to_expected_default_filters() {
assert_eq!(default_filter_for_verbosity(0), "wakey=info,tower_http=info");
assert_eq!(default_filter_for_verbosity(1), "wakey=debug,tower_http=debug");
assert_eq!(default_filter_for_verbosity(2), "wakey=trace,tower_http=trace");
assert_eq!(default_filter_for_verbosity(9), "wakey=trace,tower_http=trace");
assert_eq!(default_filter_for_verbosity(0), "wakey=info");
assert_eq!(default_filter_for_verbosity(1), "wakey=debug");
assert_eq!(default_filter_for_verbosity(2), "wakey=trace");
assert_eq!(default_filter_for_verbosity(9), "wakey=trace");
}
}
-234
View File
@@ -1,234 +0,0 @@
//! Compatibility types and mappers for the legacy HTTP/static client.
//!
//! These types intentionally preserve old JSON shapes expected by `/static`
//! while the core and service layers evolve underneath them. They do not define
//! the long-term domain model of the project.
use serde::Serialize;
use wakey_core::parse::mac;
use wakey_core::{
Device, DeviceFilters, DeviceInventory, DhcpLeaseWithState, NeighborEntry, Status, WakeResult,
WakeTargetResult,
};
/// Legacy status row shape expected by the old `/static` frontend.
#[derive(Debug, Clone, Serialize)]
pub struct LegacyStatusRow {
pub ip: std::net::IpAddr,
pub dev: Option<String>,
#[serde(with = "mac::option_mac")]
pub mac: Option<macaddr::MacAddr>,
pub state: wakey_core::NeighborState,
}
/// Legacy status response shape expected by the old `/static` frontend.
#[derive(Debug, Clone, Serialize)]
pub struct LegacyStatusResponse {
pub name: Option<String>,
pub table: Vec<LegacyStatusRow>,
pub filters: DeviceFilters,
}
/// Legacy DHCP lease row shape expected by the old `/static` frontend.
#[derive(Debug, Clone, Serialize)]
pub struct LegacyLeaseRow {
pub expires_epoch: u64,
pub ip: std::net::IpAddr,
#[serde(with = "mac")]
pub mac: macaddr::MacAddr,
pub name: Option<String>,
pub nud_state: Option<wakey_core::NeighborState>,
}
/// Legacy wake response wrapper expected by the old `/static` frontend.
#[derive(Debug, Clone, Serialize)]
pub struct LegacyWakeResult {
pub result: Vec<LegacyWakeResultRow>,
}
/// Legacy per-target wake row shape.
#[derive(Debug, Clone, Copy, Serialize)]
pub struct LegacyWakeResultRow {
#[serde(flatten)]
pub target: wakey_core::WakeTarget,
pub status: wakey_core::WakeStatus,
}
/// Map legacy-style status rows into the old response shape.
///
/// This helper exists for compatibility with the original frontend contract.
pub fn legacy_status_from_domain(status: Status<NeighborEntry>) -> LegacyStatusResponse {
LegacyStatusResponse {
name: status.name,
table: status.table.into_iter().map(legacy_status_row).collect(),
filters: status.filters,
}
}
/// Project a device inventory into the legacy status response shape.
pub fn legacy_status_from_inventory(
inventory: DeviceInventory,
name: Option<String>,
filters: DeviceFilters,
) -> LegacyStatusResponse {
let table = inventory
.devices
.into_iter()
.flat_map(legacy_status_rows_from_device)
.collect();
LegacyStatusResponse {
name,
table,
filters,
}
}
/// Convert one neighbor row to the legacy status row shape.
pub fn legacy_status_row(row: NeighborEntry) -> LegacyStatusRow {
LegacyStatusRow {
ip: row.ip,
dev: row.dev,
mac: row.mac,
state: row.state,
}
}
/// Project one merged device back into legacy status rows.
pub fn legacy_status_rows_from_device(device: Device) -> Vec<LegacyStatusRow> {
if !device.neighbors.is_empty() {
return device
.neighbors
.into_iter()
.map(legacy_status_row)
.collect::<Vec<_>>();
}
let fallback_mac = device.macs.first().copied();
let fallback_dev = device.interfaces.first().cloned();
let fallback_state = match device.presence {
wakey_core::Presence::Online => wakey_core::NeighborState::Reachable,
wakey_core::Presence::LikelyOnline => wakey_core::NeighborState::Stale,
wakey_core::Presence::Offline => wakey_core::NeighborState::Failed,
wakey_core::Presence::Unknown => wakey_core::NeighborState::None,
};
device
.ips
.into_iter()
.map(|ip| LegacyStatusRow {
ip,
dev: fallback_dev.clone(),
mac: fallback_mac,
state: fallback_state,
})
.collect()
}
/// Convert lease rows into the legacy frontend shape.
pub fn legacy_leases_from_domain(leases: Vec<DhcpLeaseWithState>) -> Vec<LegacyLeaseRow> {
leases.into_iter().map(legacy_lease_row).collect()
}
/// Convert one lease row into the legacy frontend shape.
pub fn legacy_lease_row(lease: DhcpLeaseWithState) -> LegacyLeaseRow {
LegacyLeaseRow {
expires_epoch: lease.lease_line.expires_epoch,
ip: lease.lease_line.ip,
mac: lease.lease_line.mac,
name: lease.lease_line.name,
nud_state: lease.nud_state,
}
}
/// Convert wake results into the legacy frontend shape.
pub fn legacy_wake_from_domain(result: WakeResult) -> LegacyWakeResult {
LegacyWakeResult {
result: result.result.into_iter().map(legacy_wake_row).collect(),
}
}
/// Convert one wake result row into the legacy frontend shape.
pub fn legacy_wake_row(row: WakeTargetResult) -> LegacyWakeResultRow {
LegacyWakeResultRow {
target: row.target,
status: row.status,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::{IpAddr, Ipv4Addr};
use wakey_core::{DhcpLease, NeighborState, WakeStatus, WakeTarget};
#[test]
fn maps_status_to_legacy_shape() {
let status = Status {
name: Some("pc".into()),
table: vec![NeighborEntry {
ip: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10)),
dev: Some("br-lan".into()),
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
state: NeighborState::Reachable,
}],
filters: DeviceFilters::default(),
};
let legacy = legacy_status_from_domain(status);
assert_eq!(legacy.name.as_deref(), Some("pc"));
assert_eq!(legacy.table.len(), 1);
assert_eq!(legacy.table[0].state, NeighborState::Reachable);
}
#[test]
fn maps_inventory_to_legacy_status_shape() {
let inventory = DeviceInventory {
devices: vec![Device::from_parts(
vec![NeighborEntry {
ip: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10)),
dev: Some("br-lan".into()),
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
state: NeighborState::Reachable,
}],
vec![],
)],
};
let legacy =
legacy_status_from_inventory(inventory, Some("pc".into()), DeviceFilters::default());
assert_eq!(legacy.name.as_deref(), Some("pc"));
assert_eq!(legacy.table.len(), 1);
assert_eq!(legacy.table[0].state, NeighborState::Reachable);
}
#[test]
fn maps_leases_to_legacy_shape() {
let leases = vec![DhcpLeaseWithState {
lease_line: DhcpLease {
expires_epoch: 42,
ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
mac: "aa:bb:cc:dd:ee:ff".parse().expect("mac"),
name: Some("pc".into()),
},
nud_state: Some(NeighborState::Reachable),
}];
let legacy = legacy_leases_from_domain(leases);
assert_eq!(legacy.len(), 1);
assert_eq!(legacy[0].name.as_deref(), Some("pc"));
assert_eq!(legacy[0].nud_state, Some(NeighborState::Reachable));
}
#[test]
fn maps_wake_to_legacy_shape() {
let result = WakeResult {
result: vec![WakeTargetResult {
target: WakeTarget {
ip: Some(IpAddr::V4(Ipv4Addr::LOCALHOST)),
mac: Some("aa:bb:cc:dd:ee:ff".parse().expect("mac")),
},
status: WakeStatus::Succeed,
}],
};
let legacy = legacy_wake_from_domain(result);
assert_eq!(legacy.result.len(), 1);
assert_eq!(legacy.result[0].status, WakeStatus::Succeed);
}
}
-53
View File
@@ -1,53 +0,0 @@
//! Temporary HTTP adapter for the legacy web/static surface.
//!
//! This module exists to keep the old `/api` routes and `/static` frontend
//! working while the project is migrated toward a service-first architecture.
//! New product logic should live in [`crate::service`], not here.
pub mod compat;
pub mod route;
use std::{io, net::SocketAddr};
use axum::Router;
use tokio::net::TcpListener;
use tower_http::{services::ServeDir, trace::TraceLayer};
use tracing::info;
/// Build the temporary HTTP app that serves the legacy API and static frontend.
///
/// This is a compatibility surface. It should stay thin and delegate actual
/// product behavior to the service layer.
pub fn http_app(static_root: std::path::PathBuf) -> Router {
Router::new()
.nest("/api", route::api_router())
.layer(TraceLayer::new_for_http())
.fallback_service(axum::routing::get_service(
ServeDir::new(static_root)
.append_index_html_on_directories(true)
.precompressed_br()
.precompressed_deflate()
.precompressed_gzip()
.precompressed_zstd(),
))
}
/// Serve the temporary HTTP app on the provided socket address.
///
/// This is intended for transition and compatibility, not as the long-term
/// architecture boundary of the project.
pub async fn serve_http(addr: SocketAddr, static_root: std::path::PathBuf) -> io::Result<()> {
info!(%addr, static_root = %static_root.display(), "starting legacy http adapter");
let listener = TcpListener::bind(addr).await?;
axum::serve(listener, http_app(static_root).into_make_service()).await
}
/// Serve the HTTP app using the `static/` directory next to the current
/// executable.
pub async fn serve_http_from_current_exe(addr: SocketAddr) -> io::Result<()> {
let exe = std::env::current_exe()?;
let root = exe
.parent()
.ok_or_else(|| io::Error::other("no parent dir"))?;
serve_http(addr, root.join("static")).await
}
-44
View File
@@ -1,44 +0,0 @@
use crate::http::route::error::ApiError;
use axum::Json;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::{extract::Path, response::Redirect};
use wakey_core::DeviceQuery;
use crate::http::route::status::NamePath;
// Smart redirect: accept IP, MAC, dev, or NUD state and redirect to /api/status accordingly
pub async fn status_smart_redirect(
Path(q): Path<String>,
) -> axum::response::Result<Redirect, impl IntoResponse> {
let query: DeviceQuery = match crate::service::resolve_query(q).await {
Ok(query) => query,
Err(e) => {
return Err(ApiError {
error: e.to_string(),
code: StatusCode::BAD_GATEWAY,
});
}
};
match serde_html_form::to_string(query) {
Ok(e) => Ok(Redirect::to(&format!("/api/status?{e}"))),
Err(e) => Err(ApiError {
error: e.to_string(),
code: StatusCode::BAD_GATEWAY,
}),
}
}
pub async fn status_redirect(Path(NamePath { name }): Path<NamePath>) -> Redirect {
Redirect::permanent(&format!(
"/api/status?name={name}",
name = urlencoding::encode(&name) // just for
))
}
pub async fn ip(Path(name): Path<String>) -> impl IntoResponse {
crate::service::get_ips(&name).await.map_or_else(
|e| ApiError::ise(e.to_string()).into_response(),
|ips| Json(ips).into_response(),
)
}
-10
View File
@@ -1,10 +0,0 @@
use crate::http::route::error::ApiError;
use axum::{Json, response::IntoResponse};
pub async fn devs_router() -> impl IntoResponse {
match crate::service::list_interfaces().await {
Ok(devs) => Json(devs).into_response(),
Err(e) => ApiError::ise(e.to_string()).into_response(),
}
}
// Device listing endpoints
-27
View File
@@ -1,27 +0,0 @@
use crate::{http::route::error::ApiError, utils::parse::boolish_str};
use axum::{Json, extract::Query, http::StatusCode, response::IntoResponse};
// DHCP lease endpoints
#[derive(Debug, Default, Clone, serde::Deserialize)]
pub struct DhcpLeasesQueryRaw {
include_state: Option<String>,
}
pub async fn get_dhcp_leases(
Query(DhcpLeasesQueryRaw { include_state }): Query<DhcpLeasesQueryRaw>,
) -> impl IntoResponse {
let include_state = include_state.as_deref().map(boolish_str).unwrap_or(false);
match crate::service::get_leases(wakey_core::LeaseQuery { include_state }).await {
Ok(leases) => (
StatusCode::OK,
Json(crate::http::compat::legacy_leases_from_domain(leases)),
)
.into_response(),
Err(e) => ApiError {
error: e.to_string(),
code: StatusCode::BAD_GATEWAY,
}
.into_response(),
}
}
-29
View File
@@ -1,29 +0,0 @@
use axum::{
Json,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Serialize;
#[derive(Debug, Serialize)]
pub struct ApiError<T: Serialize> {
#[serde(skip_serializing, skip_deserializing)]
pub code: StatusCode,
pub error: T,
}
impl<T: Serialize> ApiError<T> {
/// [StatusCode::INTERNAL_SERVER_ERROR] shortcut
pub const fn ise(error: T) -> Self {
Self {
code: StatusCode::INTERNAL_SERVER_ERROR,
error,
}
}
}
impl<T: Serialize> IntoResponse for ApiError<T> {
fn into_response(self) -> Response {
(self.code, Json(self)).into_response()
}
}
-55
View File
@@ -1,55 +0,0 @@
pub mod api;
pub mod devs;
pub mod dhcp;
pub mod error;
pub mod status;
pub mod wake;
use crate::http::route::api::ip;
use crate::http::route::api::status_redirect;
use crate::http::route::api::status_smart_redirect;
use crate::http::route::devs::devs_router;
use crate::http::route::dhcp::get_dhcp_leases;
use crate::http::route::error::ApiError;
use crate::http::route::status::get_status_json;
use crate::http::route::wake::wake_multi;
use crate::legacy::dhcpparse::load_mac_name_cache;
use axum::Json;
use axum::Router;
use axum::body::Body;
use axum::http::Request;
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use std::time::Instant;
async fn add_performance_header(req: Request<Body>, next: Next) -> Response {
let start = Instant::now();
let mut response = next.run(req).await;
let elapsed = start.elapsed();
if let Ok(val) = format!("work-time={}us", elapsed.as_micros()).parse() {
response.headers_mut().insert("Lda-Performance", val);
}
response
}
pub fn api_router() -> Router {
Router::new()
.route("/status/{name}", get(status_redirect))
.route("/status", get(get_status_json))
.route("/dhcp_leases", get(get_dhcp_leases))
.route("/smart/{q}", get(status_smart_redirect))
.route("/devs", get(devs_router))
.route("/wake", post(wake_multi))
.route("/ips/{name}", get(ip))
.route(
"/mac-cache",
get(async || match load_mac_name_cache().await {
Ok(h) => Json(h).into_response(),
Err(e) => ApiError::ise(e.to_string()).into_response(),
}),
)
.layer(middleware::from_fn(add_performance_header))
}
-28
View File
@@ -1,28 +0,0 @@
use crate::http::route::error::ApiError;
use axum::{Json, http::StatusCode, response::IntoResponse};
use axum_extra::extract::Query;
pub use wakey_core::{DeviceQuery, NamePath};
pub async fn get_status_json(Query(query): Query<DeviceQuery>) -> impl IntoResponse {
match crate::service::inventory(query.clone()).await {
Ok(inventory) => (
StatusCode::OK,
Json(crate::http::compat::legacy_status_from_inventory(
inventory,
query.name,
query.filter,
)),
)
.into_response(),
Err(error) => ApiError {
code: StatusCode::BAD_GATEWAY,
error: error
.chain()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(": "),
}
.into_response(),
}
}
// Status endpoints
-17
View File
@@ -1,17 +0,0 @@
use crate::http::route::error::ApiError;
use axum::{extract::Json, http::StatusCode, response::IntoResponse};
pub use wakey_core::WakeTarget;
pub async fn wake_multi(Json(req): Json<Vec<WakeTarget>>) -> impl IntoResponse {
match crate::service::wake_targets(req).await {
Ok(result) => (
StatusCode::OK,
Json(crate::http::compat::legacy_wake_from_domain(result)),
)
.into_response(),
Err(error) => {
let error = format!("Error: {}", error);
ApiError::ise(error).into_response()
}
}
}
-2
View File
@@ -1,2 +0,0 @@
pub use wakey_core::NeighborEntry as IpNeighLine;
pub use wakey_core::NeighborState as NUDState;
-1
View File
@@ -1 +0,0 @@
pub use wakey_linux::dhcp::{load_mac_name_cache, read_dhcp_leases_with_names};
-8
View File
@@ -1,8 +0,0 @@
//! Transitional compatibility wrappers preserved during the migration.
//!
//! Items in this module exist so the codebase can keep working while older
//! parsing paths and adapter surfaces are being retired or replaced. New logic
//! should prefer the service layer and the dedicated crate boundaries instead.
pub mod arpparse;
pub mod dhcpparse;
-3
View File
@@ -1,9 +1,6 @@
pub mod http;
pub mod legacy;
pub mod service;
pub mod utils;
pub use http::{http_app, serve_http, serve_http_from_current_exe};
pub use service::{
StatusResponse, broadcast_wake_targets, device_to_status_rows, get_interface_summaries,
get_interface_summary, get_ips, get_leases, get_status, get_status_for_input, inventory,
-4
View File
@@ -12,7 +12,3 @@
// this is so bad
pub mod ping;
pub mod query;
// no custom ip deserializer needed when using axum_extra::extract::Query
// but we add a generic one to ignore blanks and accept OneOrMany
pub(crate) mod parse;
-41
View File
@@ -1,41 +0,0 @@
/// key for yes: "1" | "true" | "yes" | "on" | "y"
///
/// frfr
pub fn _de_boolish<'de, D>(des: D) -> Result<bool, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::Deserialize;
#[derive(Deserialize)]
#[serde(untagged)]
enum Boolish {
B(bool),
I(u8),
S(String),
}
Ok(match Boolish::deserialize(des)? {
Boolish::B(b) => b,
Boolish::I(i) => i != 0,
Boolish::S(s) => {
let t = s.trim().to_ascii_lowercase();
if t.is_empty() {
true // presence implies true
} else {
matches!(t.as_str(), "1" | "true" | "yes" | "on" | "y")
}
}
})
}
/// Parse a tolerant boolean value from a string.
/// Accepts: "1", "true", "yes", "on", "y" as true; "0", "false", "no", "off", "n" as false.
/// Empty string means true (presence-only query flag).
pub fn boolish_str(s: &str) -> bool {
let t = s.trim().to_ascii_lowercase();
if t.is_empty() {
return true;
}
matches!(t.as_str(), "1" | "true" | "yes" | "on" | "y")
|| (!matches!(t.as_str(), "0" | "false" | "no" | "off" | "n")
&& t.parse::<u64>().map(|n| n != 0).unwrap_or(false))
}
+4 -3
View File
@@ -6,8 +6,9 @@ use tokio::{
net::{TcpStream, ToSocketAddrs},
time::timeout,
};
use wakey_core::NeighborState;
use crate::{legacy::arpparse::NUDState, utils::query::get_mac};
use crate::utils::query::get_mac;
pub async fn _ping_ip<T: ToSocketAddrs>(addr: T) -> bool {
timeout(Duration::from_secs(1), TcpStream::connect(addr))
@@ -19,13 +20,13 @@ pub async fn _ping_ip_2<T: ToSocketAddrs>(_addr: T) -> bool {
}
pub async fn _ping_ip_3<T: Into<IpAddr>>(addr: T) -> u8 {
match get_mac(Some(addr.into()), None, &[] as &[NUDState]).await {
match get_mac(Some(addr.into()), None, &[] as &[NeighborState]).await {
Err(_) => 0,
Ok(l) => l
.into_iter()
.map(|e| e.state)
.max()
.map(NUDState::rank)
.map(NeighborState::rank)
.unwrap_or_default(),
}
}
+5 -6
View File
@@ -16,8 +16,7 @@ pub use macs::*;
pub mod macs {
use anyhow::Result;
use std::net::IpAddr;
use crate::legacy::arpparse::{IpNeighLine, NUDState};
use wakey_core::{NeighborEntry, NeighborState};
pub async fn get_ips(machine_name: &str) -> Result<impl Iterator<Item = IpAddr>> {
wakey_linux::devices::get_ips(machine_name).await
@@ -27,17 +26,17 @@ pub mod macs {
machine_names: &[impl AsRef<str>],
ips: &[IpAddr],
devs: &[impl AsRef<str>],
state: &[NUDState],
state: &[NeighborState],
macs: &[macaddr::MacAddr],
) -> Result<Vec<IpNeighLine>> {
) -> Result<Vec<NeighborEntry>> {
wakey_linux::devices::get_neighbors(machine_names, ips, devs, state, macs).await
}
pub async fn get_mac(
ip: Option<IpAddr>,
dev: Option<&str>,
state: &[NUDState],
) -> Result<Vec<IpNeighLine>> {
state: &[NeighborState],
) -> Result<Vec<NeighborEntry>> {
let ips: Vec<IpAddr> = ip.into_iter().collect();
let devs: Vec<&str> = dev.into_iter().collect();
get_macs(&[] as &[&str], &ips, &devs, state, &[]).await