feat(sensing): native macOS CoreWLAN WiFi sensing adapter
Add native macOS LiDAR / WiFi sensing support via CoreWLAN: - mac_wifi.swift: Swift helper to poll RSSI/Noise at 10Hz - MacosWifiCollector: Python adapter for the sensing pipeline - Auto-detect Darwin platform in ws_server.py
This commit is contained in:
34
v1/src/sensing/mac_wifi.swift
Normal file
34
v1/src/sensing/mac_wifi.swift
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import Foundation
|
||||||
|
import CoreWLAN
|
||||||
|
|
||||||
|
// Output format: JSON lines for easy parsing by Python
|
||||||
|
// {"timestamp": 1234567.89, "rssi": -50, "noise": -90, "tx_rate": 866.0}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
guard let interface = CWWiFiClient.shared().interface() else {
|
||||||
|
fputs("{\"error\": \"No WiFi interface found\"}\n", stderr)
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush stdout automatically to prevent buffering issues with Python subprocess
|
||||||
|
setbuf(stdout, nil)
|
||||||
|
|
||||||
|
// Run at ~10Hz
|
||||||
|
let interval: TimeInterval = 0.1
|
||||||
|
|
||||||
|
while true {
|
||||||
|
let timestamp = Date().timeIntervalSince1970
|
||||||
|
let rssi = interface.rssiValue()
|
||||||
|
let noise = interface.noiseMeasurement()
|
||||||
|
let txRate = interface.transmitRate()
|
||||||
|
|
||||||
|
let json = """
|
||||||
|
{"timestamp": \(timestamp), "rssi": \(rssi), "noise": \(noise), "tx_rate": \(txRate)}
|
||||||
|
"""
|
||||||
|
print(json)
|
||||||
|
|
||||||
|
Thread.sleep(forTimeInterval: interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
@@ -602,3 +602,143 @@ class WindowsWifiCollector:
|
|||||||
retry_count=0,
|
retry_count=0,
|
||||||
interface=self._interface,
|
interface=self._interface,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# macOS WiFi collector (real hardware via Swift CoreWLAN utility)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class MacosWifiCollector:
|
||||||
|
"""
|
||||||
|
Collects real RSSI data from a macOS WiFi interface using a Swift utility.
|
||||||
|
|
||||||
|
Data source: A small compiled Swift binary (`mac_wifi`) that polls the
|
||||||
|
CoreWLAN `CWWiFiClient.shared().interface()` at a high rate.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
sample_rate_hz: float = 10.0,
|
||||||
|
buffer_seconds: int = 120,
|
||||||
|
) -> None:
|
||||||
|
self._rate = sample_rate_hz
|
||||||
|
self._buffer = RingBuffer(max_size=int(sample_rate_hz * buffer_seconds))
|
||||||
|
self._running = False
|
||||||
|
self._thread: Optional[threading.Thread] = None
|
||||||
|
self._process: Optional[subprocess.Popen] = None
|
||||||
|
self._interface = "en0" # CoreWLAN automatically targets the active Wi-Fi interface
|
||||||
|
|
||||||
|
# Compile the Swift utility if the binary doesn't exist
|
||||||
|
import os
|
||||||
|
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
self.swift_src = os.path.join(base_dir, "mac_wifi.swift")
|
||||||
|
self.swift_bin = os.path.join(base_dir, "mac_wifi")
|
||||||
|
|
||||||
|
# -- public API ----------------------------------------------------------
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sample_rate_hz(self) -> float:
|
||||||
|
return self._rate
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Ensure binary exists
|
||||||
|
import os
|
||||||
|
if not os.path.exists(self.swift_bin):
|
||||||
|
logger.info("Compiling mac_wifi.swift to %s", self.swift_bin)
|
||||||
|
try:
|
||||||
|
subprocess.run(["swiftc", "-O", "-o", self.swift_bin, self.swift_src], check=True, capture_output=True)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
raise RuntimeError(f"Failed to compile macOS WiFi utility: {e.stderr.decode('utf-8')}")
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise RuntimeError("swiftc is not installed. Please install Xcode Command Line Tools to use native macOS WiFi sensing.")
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
self._thread = threading.Thread(
|
||||||
|
target=self._sample_loop, daemon=True, name="mac-rssi-collector"
|
||||||
|
)
|
||||||
|
self._thread.start()
|
||||||
|
logger.info("MacosWifiCollector started at %.1f Hz", self._rate)
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._running = False
|
||||||
|
if self._process:
|
||||||
|
self._process.terminate()
|
||||||
|
try:
|
||||||
|
self._process.wait(timeout=1.0)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
self._process.kill()
|
||||||
|
self._process = None
|
||||||
|
|
||||||
|
if self._thread is not None:
|
||||||
|
self._thread.join(timeout=2.0)
|
||||||
|
self._thread = None
|
||||||
|
logger.info("MacosWifiCollector stopped")
|
||||||
|
|
||||||
|
def get_samples(self, n: Optional[int] = None) -> List[WifiSample]:
|
||||||
|
if n is not None:
|
||||||
|
return self._buffer.get_last_n(n)
|
||||||
|
return self._buffer.get_all()
|
||||||
|
|
||||||
|
# -- internals -----------------------------------------------------------
|
||||||
|
|
||||||
|
def _sample_loop(self) -> None:
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Start the Swift binary
|
||||||
|
self._process = subprocess.Popen(
|
||||||
|
[self.swift_bin],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
bufsize=1 # Line buffered
|
||||||
|
)
|
||||||
|
|
||||||
|
synth_tx = 0
|
||||||
|
synth_rx = 0
|
||||||
|
|
||||||
|
while self._running and self._process and self._process.poll() is None:
|
||||||
|
try:
|
||||||
|
line = self._process.stdout.readline()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if line.startswith("{"):
|
||||||
|
data = json.loads(line)
|
||||||
|
if "error" in data:
|
||||||
|
logger.error("macOS WiFi utility error: %s", data["error"])
|
||||||
|
continue
|
||||||
|
|
||||||
|
rssi = float(data.get("rssi", -80.0))
|
||||||
|
noise = float(data.get("noise", -95.0))
|
||||||
|
|
||||||
|
link_quality = max(0.0, min(1.0, (rssi + 100.0) / 60.0))
|
||||||
|
|
||||||
|
synth_tx += 1500
|
||||||
|
synth_rx += 3000
|
||||||
|
|
||||||
|
sample = WifiSample(
|
||||||
|
timestamp=time.time(),
|
||||||
|
rssi_dbm=rssi,
|
||||||
|
noise_dbm=noise,
|
||||||
|
link_quality=link_quality,
|
||||||
|
tx_bytes=synth_tx,
|
||||||
|
rx_bytes=synth_rx,
|
||||||
|
retry_count=0,
|
||||||
|
interface=self._interface,
|
||||||
|
)
|
||||||
|
self._buffer.append(sample)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error reading macOS WiFi stream: %s", e)
|
||||||
|
time.sleep(1.0)
|
||||||
|
|
||||||
|
# Process exited unexpectedly
|
||||||
|
if self._running:
|
||||||
|
logger.error("macOS WiFi utility exited unexpectedly. Collector stopped.")
|
||||||
|
self._running = False
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ from v1.src.sensing.rssi_collector import (
|
|||||||
LinuxWifiCollector,
|
LinuxWifiCollector,
|
||||||
SimulatedCollector,
|
SimulatedCollector,
|
||||||
WindowsWifiCollector,
|
WindowsWifiCollector,
|
||||||
|
MacosWifiCollector,
|
||||||
WifiSample,
|
WifiSample,
|
||||||
RingBuffer,
|
RingBuffer,
|
||||||
)
|
)
|
||||||
@@ -340,12 +341,26 @@ class SensingWebSocketServer:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Windows WiFi unavailable (%s), falling back", e)
|
logger.warning("Windows WiFi unavailable (%s), falling back", e)
|
||||||
elif system == "Linux":
|
elif system == "Linux":
|
||||||
|
# In Docker on Mac, Linux is detected but no wireless extensions exist.
|
||||||
|
# Force SimulatedCollector if /proc/net/wireless doesn't exist.
|
||||||
|
import os
|
||||||
|
if os.path.exists("/proc/net/wireless"):
|
||||||
|
try:
|
||||||
|
collector = LinuxWifiCollector(sample_rate_hz=10.0)
|
||||||
|
self.source = "linux_wifi"
|
||||||
|
return collector
|
||||||
|
except RuntimeError:
|
||||||
|
logger.warning("Linux WiFi unavailable, falling back")
|
||||||
|
else:
|
||||||
|
logger.warning("Linux detected but /proc/net/wireless missing (likely Docker). Falling back.")
|
||||||
|
elif system == "Darwin":
|
||||||
try:
|
try:
|
||||||
collector = LinuxWifiCollector(sample_rate_hz=10.0)
|
collector = MacosWifiCollector(sample_rate_hz=10.0)
|
||||||
self.source = "linux_wifi"
|
logger.info("Using MacosWifiCollector")
|
||||||
|
self.source = "macos_wifi"
|
||||||
return collector
|
return collector
|
||||||
except RuntimeError:
|
except Exception as e:
|
||||||
logger.warning("Linux WiFi unavailable, falling back")
|
logger.warning("macOS WiFi unavailable (%s), falling back", e)
|
||||||
|
|
||||||
# 3. Simulated
|
# 3. Simulated
|
||||||
logger.info("Using SimulatedCollector")
|
logger.info("Using SimulatedCollector")
|
||||||
|
|||||||
Reference in New Issue
Block a user