""" Linux 真实实现层 ================ 在 Linux 环境下,将 Android API 的存根行为替换为真实的系统调用。 使 Android-pyApi 从"存根库"变为"可用的生产力工具"。 覆盖能力: - 网络: ip 命令, /proc/net, socket - 蓝牙: bluetoothctl / dbus - 传感器: /sys/class/hwmon, /sys/class/thermal, sensors - 音频: amixer / ALSA - 剪贴板: xclip - 电源/电池: /sys/class/power_supply, psutil - 位置: IP 地理定位 (requests) - 通知: notify-send - Shell: subprocess - 显示: xdotool, X11 - UI自动化: xdotool - 摄像头: opencv (V4L2) - 进程管理: psutil, /proc - 文件: 原生 Python os - 媒体: ffmpeg/ffprobe """ from __future__ import annotations import os import sys import re import json import time import socket import struct import shutil import subprocess import platform import hashlib from pathlib import Path from datetime import datetime from typing import Any, Dict, List, Optional, Tuple # ═══════════════════════════════════════════════════════════════ # 能力检测 # ═══════════════════════════════════════════════════════════════ IS_LINUX = platform.system() == "Linux" def _which(name: str) -> Optional[str]: return shutil.which(name) HAS_IP = _which("ip") is not None HAS_BLUETOOTHCTL = _which("bluetoothctl") is not None HAS_AMIXER = _which("amixer") is not None HAS_XCLIP = _which("xclip") is not None HAS_XDOTOOL = _which("xdotool") is not None HAS_NOTIFY_SEND = _which("notify-send") is not None HAS_SENSORS = _which("sensors") is not None HAS_FFMPEG = _which("ffmpeg") is not None HAS_FFPROBE = _which("ffprobe") is not None HAS_XRANDR = _which("xrandr") is not None try: import psutil HAS_PSUTIL = True except ImportError: HAS_PSUTIL = False try: import requests HAS_REQUESTS = True except ImportError: HAS_REQUESTS = False try: import cv2 HAS_CV2 = True except ImportError: HAS_CV2 = False try: import numpy as np HAS_NUMPY = True except ImportError: HAS_NUMPY = False try: from PIL import Image, ImageDraw, ImageGrab HAS_PIL = True except ImportError: HAS_PIL = False try: import dbus HAS_DBUS = True except ImportError: HAS_DBUS = False def _run(cmd: List[str] or str, timeout: int = 10, capture: bool = True) -> Tuple[int, str, str]: """执行命令,返回 (exit_code, stdout, stderr)""" try: if isinstance(cmd, str): result = subprocess.run(cmd, shell=True, capture_output=capture, text=True, timeout=timeout) else: result = subprocess.run(cmd, capture_output=capture, text=True, timeout=timeout) return result.returncode, result.stdout.strip(), result.stderr.strip() except subprocess.TimeoutExpired: return -1, "", "timeout" except Exception as e: return -1, "", str(e) def _read_file(path: str) -> Optional[str]: try: with open(path, "r") as f: return f.read().strip() except Exception: return None def _read_file_int(path: str) -> Optional[int]: val = _read_file(path) if val is not None: try: return int(val) except ValueError: return None return None def _list_dir(path: str) -> List[str]: try: return os.listdir(path) except Exception: return [] # ═══════════════════════════════════════════════════════════════ # 网络 — 真实实现 # ═══════════════════════════════════════════════════════════════ class LinuxNetwork: """真实网络信息 — 通过 ip 命令和 /proc/net""" @staticmethod def get_interfaces() -> List[dict]: """获取网络接口列表""" if not HAS_IP: return [] code, out, _ = _run(["ip", "-j", "addr", "show"]) if code == 0 and out: try: return json.loads(out) except json.JSONDecodeError: pass # 回退到 /proc/net interfaces = [] for name in _list_dir("/sys/class/net/"): state = _read_file(f"/sys/class/net/{name}/operstate") or "unknown" mac = _read_file(f"/sys/class/net/{name}/address") or "" interfaces.append({"ifname": name, "operstate": state, "address": mac}) return interfaces @staticmethod def get_ip_address(interface: str = None) -> str: """获取IP地址""" # 优先用 socket 获取外网IP try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.connect(("8.8.8.8", 80)) ip = s.getsockname()[0] s.close() return ip except Exception: pass # 回退到 hostname try: return socket.gethostbyname(socket.gethostname()) except Exception: return "127.0.0.1" @staticmethod def get_wifi_info() -> dict: """获取WiFi信息""" # 尝试 /proc/net/wireless data = _read_file("/proc/net/wireless") if data: lines = data.strip().split("\n") if len(lines) >= 3: parts = lines[2].split() if len(parts) >= 7: return { "ssid": "", "interface": parts[0].rstrip(":"), "status": parts[1], "quality": float(parts[2]), "signal": float(parts[3]), "noise": float(parts[4]), } # 尝试 nmcli code, out, _ = _run(["nmcli", "-t", "-f", "active,ssid,signal", "dev", "wifi"]) if code == 0 and out: for line in out.split("\n"): if line.startswith("yes:"): parts = line.split(":") if len(parts) >= 3: return {"ssid": parts[1], "signal": int(parts[2])} return {"ssid": "", "signal": 0} @staticmethod def get_connectivity() -> bool: """检测网络连通性""" # 方法1: 尝试 TCP 连接到公共 DNS for host, port in [("8.8.8.8", 53), ("1.1.1.1", 53), ("114.114.114.114", 53)]: try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(3) s.connect((host, port)) s.close() return True except Exception: continue # 方法2: 尝试 HTTP 请求 if HAS_REQUESTS: try: requests.get("http://www.baidu.com", timeout=3) return True except Exception: pass # 方法3: 检查是否有非 lo 接口且状态为 UP for iface in LinuxNetwork.get_interfaces(): name = iface.get("ifname", iface.get("name", "")) state = iface.get("operstate", iface.get("state", "")) if name and name != "lo" and state in ("up", "UP"): return True return False @staticmethod def get_dns_servers() -> List[str]: """获取DNS服务器""" data = _read_file("/etc/resolv.conf") if data: dns = [] for line in data.split("\n"): line = line.strip() if line.startswith("nameserver"): parts = line.split() if len(parts) >= 2: dns.append(parts[1]) return dns return [] @staticmethod def get_gateway() -> str: """获取默认网关""" data = _read_file("/proc/net/route") if data: for line in data.split("\n")[1:]: parts = line.split() if len(parts) >= 3 and parts[1] == "00000000": # 网关是十六进制小端 gw_hex = parts[2] try: gw_bytes = bytes.fromhex(gw_hex) gw = socket.inet_ntoa(gw_bytes[::-1]) return gw except Exception: pass return "" @staticmethod def get_mac_address(interface: str = None) -> str: """获取MAC地址""" if interface is None: ifaces = _list_dir("/sys/class/net/") if ifaces and "lo" in ifaces: ifaces.remove("lo") if ifaces: interface = ifaces[0] if interface: return _read_file(f"/sys/class/net/{interface}/address") or "" return "" @staticmethod def get_rx_tx_bytes(interface: str = None) -> dict: """获取网络收发字节数""" if interface is None: ifaces = _list_dir("/sys/class/net/") if "lo" in ifaces: ifaces.remove("lo") if ifaces: interface = ifaces[0] if interface: rx = _read_file_int(f"/sys/class/net/{interface}/statistics/rx_bytes") or 0 tx = _read_file_int(f"/sys/class/net/{interface}/statistics/tx_bytes") or 0 return {"rx_bytes": rx, "tx_bytes": tx, "interface": interface} return {"rx_bytes": 0, "tx_bytes": 0, "interface": ""} # ═══════════════════════════════════════════════════════════════ # 蓝牙 — 真实实现 (bluetoothctl) # ═══════════════════════════════════════════════════════════════ class LinuxBluetooth: """真实蓝牙操作 — 通过 bluetoothctl""" STATE_OFF = 10 STATE_TURNING_ON = 11 STATE_ON = 12 STATE_TURNING_OFF = 13 @staticmethod def get_state() -> int: if not HAS_BLUETOOTHCTL: return LinuxBluetooth.STATE_OFF code, out, _ = _run(["bluetoothctl", "show"]) if "Powered: yes" in out: return LinuxBluetooth.STATE_ON return LinuxBluetooth.STATE_OFF @staticmethod def enable() -> bool: if not HAS_BLUETOOTHCTL: return False code, _, _ = _run(["bluetoothctl", "power", "on"]) return code == 0 @staticmethod def disable() -> bool: if not HAS_BLUETOOTHCTL: return False code, _, _ = _run(["bluetoothctl", "power", "off"]) return code == 0 @staticmethod def get_bonded_devices() -> List[dict]: if not HAS_BLUETOOTHCTL: return [] code, out, _ = _run(["bluetoothctl", "devices", "Paired"]) devices = [] for line in out.split("\n"): if line.startswith("Device "): parts = line.split(None, 2) if len(parts) >= 3: devices.append({ "address": parts[1], "name": parts[2], "bonded": True, }) return devices @staticmethod def scan(timeout: int = 10) -> List[dict]: if not HAS_BLUETOOTHCTL: return [] # 启动扫描 _run(["bluetoothctl", "scan", "on"], timeout=2) time.sleep(min(timeout, 5)) _run(["bluetoothctl", "scan", "off"], timeout=2) # 获取设备列表 code, out, _ = _run(["bluetoothctl", "devices"]) devices = [] for line in out.split("\n"): if line.startswith("Device "): parts = line.split(None, 2) if len(parts) >= 3: devices.append({ "address": parts[1], "name": parts[2], }) return devices @staticmethod def pair(address: str) -> bool: if not HAS_BLUETOOTHCTL: return False code, _, _ = _run(["bluetoothctl", "pair", address], timeout=30) return code == 0 @staticmethod def connect(address: str) -> bool: if not HAS_BLUETOOTHCTL: return False code, _, _ = _run(["bluetoothctl", "connect", address], timeout=15) return code == 0 @staticmethod def disconnect(address: str) -> bool: if not HAS_BLUETOOTHCTL: return False code, _, _ = _run(["bluetoothctl", "disconnect", address], timeout=10) return code == 0 @staticmethod def unpair(address: str) -> bool: if not HAS_BLUETOOTHCTL: return False code, _, _ = _run(["bluetoothctl", "remove", address], timeout=10) return code == 0 # ═══════════════════════════════════════════════════════════════ # 传感器 — 真实实现 (/sys/class/hwmon, /sys/class/thermal) # ═══════════════════════════════════════════════════════════════ class LinuxSensors: """真实传感器数据 — 通过 /sys 文件系统""" # Android Sensor Types TYPE_ACCELEROMETER = 1 TYPE_GYROSCOPE = 4 TYPE_LIGHT = 5 TYPE_PRESSURE = 6 TYPE_PROXIMITY = 8 TYPE_TEMPERATURE = 7 TYPE_RELATIVE_HUMIDITY = 12 TYPE_AMBIENT_TEMPERATURE = 13 @staticmethod def get_temperatures() -> List[dict]: """获取温度传感器读数""" temps = [] # /sys/class/thermal for name in _list_dir("/sys/class/thermal/"): if name.startswith("thermal_zone"): temp = _read_file_int(f"/sys/class/thermal/{name}/temp") typ = _read_file(f"/sys/class/thermal/{name}/type") or "" if temp is not None: temps.append({ "name": name, "type": typ, "value": temp / 1000.0, # millicelsius -> celsius "unit": "°C", }) # /sys/class/hwmon for name in _list_dir("/sys/class/hwmon/"): temp_files = [f for f in _list_dir(f"/sys/class/hwmon/{name}/") if f.startswith("temp") and f.endswith("_input")] for tf in temp_files: temp = _read_file_int(f"/sys/class/hwmon/{name}/{tf}") label_file = tf.replace("_input", "_label") label = _read_file(f"/sys/class/hwmon/{name}/{label_file}") or tf if temp is not None: temps.append({ "name": f"{name}/{label}", "type": "hwmon", "value": temp / 1000.0, "unit": "°C", }) return temps @staticmethod def get_fan_speeds() -> List[dict]: """获取风扇转速""" fans = [] for name in _list_dir("/sys/class/hwmon/"): fan_files = [f for f in _list_dir(f"/sys/class/hwmon/{name}/") if f.startswith("fan") and f.endswith("_input")] for ff in fan_files: rpm = _read_file_int(f"/sys/class/hwmon/{name}/{ff}") label_file = ff.replace("_input", "_label") label = _read_file(f"/sys/class/hwmon/{name}/{label_file}") or ff if rpm is not None: fans.append({"name": label, "rpm": rpm}) return fans @staticmethod def get_cpu_frequency() -> List[dict]: """获取CPU频率""" freqs = [] for name in _list_dir("/sys/devices/system/cpu/"): if name.startswith("cpu") and name[3:].isdigit(): cpu_id = name[3:] cur = _read_file_int(f"/sys/devices/system/cpu/{name}/cpufreq/scaling_cur_freq") if cur is not None: freqs.append({ "cpu": int(cpu_id), "freq_mhz": cur / 1000.0, }) return freqs @staticmethod def get_light_level() -> Optional[float]: """获取环境光传感器 (如果有)""" for name in _list_dir("/sys/class/hwmon/"): for f in _list_dir(f"/sys/class/hwmon/{name}/"): if "in" in f and f.endswith("_input"): val = _read_file_int(f"/sys/class/hwmon/{name}/{f}") if val is not None: return val / 1000.0 return None @staticmethod def get_all_sensors() -> dict: """获取所有传感器数据""" return { "temperatures": LinuxSensors.get_temperatures(), "fans": LinuxSensors.get_fan_speeds(), "cpu_freq": LinuxSensors.get_cpu_frequency(), "light": LinuxSensors.get_light_level(), } # ═══════════════════════════════════════════════════════════════ # 音频 — 真实实现 (amixer / ALSA) # ═══════════════════════════════════════════════════════════════ class LinuxAudio: """真实音频控制 — 通过 amixer""" @staticmethod def get_volume() -> int: """获取当前音量 (0-100)""" if not HAS_AMIXER: return 0 code, out, _ = _run(["amixer", "sget", "Master"]) if code == 0: match = re.search(r'\[(\d+)%\]', out) if match: return int(match.group(1)) return 0 @staticmethod def set_volume(volume: int) -> bool: """设置音量 (0-100)""" if not HAS_AMIXER: return False volume = max(0, min(100, volume)) code, _, _ = _run(["amixer", "sset", "Master", f"{volume}%"]) return code == 0 @staticmethod def get_mute() -> bool: """获取静音状态""" if not HAS_AMIXER: return False code, out, _ = _run(["amixer", "sget", "Master"]) if code == 0: return "[on]" not in out return False @staticmethod def set_mute(mute: bool) -> bool: """设置静音""" if not HAS_AMIXER: return False cmd = "mute" if mute else "unmute" code, _, _ = _run(["amixer", "sset", "Master", cmd]) return code == 0 @staticmethod def get_audio_devices() -> List[dict]: """获取音频设备列表""" devices = [] if HAS_AMIXER: code, out, _ = _run(["amixer", "controls"]) if code == 0: for line in out.split("\n"): devices.append({"name": line, "type": "alsa"}) return devices # ═══════════════════════════════════════════════════════════════ # 剪贴板 — 真实实现 (xclip) # ═══════════════════════════════════════════════════════════════ class LinuxClipboard: """真实剪贴板 — 通过 xclip""" @staticmethod def read() -> str: if HAS_XCLIP: code, out, _ = _run(["xclip", "-selection", "clipboard", "-o"]) if code == 0: return out # 回退到 tkinter try: import tkinter root = tkinter.Tk() root.withdraw() text = root.clipboard_get() root.destroy() return text except Exception: return "" @staticmethod def write(text: str) -> bool: if HAS_XCLIP: try: proc = subprocess.Popen( ["xclip", "-selection", "clipboard"], stdin=subprocess.PIPE, text=True ) proc.communicate(text) return proc.returncode == 0 except Exception: pass # 回退到 tkinter try: import tkinter root = tkinter.Tk() root.withdraw() root.clipboard_clear() root.clipboard_append(text) root.update() root.destroy() return True except Exception: return False # ═══════════════════════════════════════════════════════════════ # 电源/电池 — 真实实现 (/sys/class/power_supply, psutil) # ═══════════════════════════════════════════════════════════════ class LinuxPower: """真实电源/电池信息""" BATTERY_STATUS_UNKNOWN = 1 BATTERY_STATUS_CHARGING = 2 BATTERY_STATUS_DISCHARGING = 3 BATTERY_STATUS_NOT_CHARGING = 4 BATTERY_STATUS_FULL = 5 @staticmethod def get_battery_info() -> dict: """获取电池信息""" info = {"present": False, "level": -1, "status": "unknown", "temperature": 0, "voltage": 0, "technology": ""} # /sys/class/power_supply for name in _list_dir("/sys/class/power_supply/"): typ = _read_file(f"/sys/class/power_supply/{name}/type") or "" if "Battery" in typ: info["present"] = True info["technology"] = _read_file(f"/sys/class/power_supply/{name}/technology") or "" capacity = _read_file_int(f"/sys/class/power_supply/{name}/capacity") if capacity is not None: info["level"] = capacity status = _read_file(f"/sys/class/power_supply/{name}/status") or "" info["status"] = status.lower() # 电压 (微伏) volt = _read_file_int(f"/sys/class/power_supply/{name}/voltage_now") if volt is not None: info["voltage"] = volt / 1000000.0 # 温度 (十分之一度) temp = _read_file_int(f"/sys/class/power_supply/{name}/temp") if temp is not None: info["temperature"] = temp / 10.0 return info # psutil 回退 if HAS_PSUTIL: try: battery = psutil.sensors_battery() if battery: info["present"] = True info["level"] = battery.percent info["status"] = "charging" if battery.power_plugged else "discharging" except Exception: pass return info @staticmethod def get_power_status() -> dict: """获取电源状态""" info = LinuxPower.get_battery_info() return { "battery_present": info["present"], "battery_level": info["level"], "battery_status": info["status"], "is_charging": "charging" in info["status"], "temperature": info["temperature"], "voltage": info["voltage"], } @staticmethod def get_cpu_usage() -> float: """获取CPU使用率""" if HAS_PSUTIL: return psutil.cpu_percent(interval=0.5) # /proc/stat 回退 def read_cpu(): data = _read_file("/proc/stat") if data: parts = data.split("\n")[0].split()[1:] return [int(x) for x in parts] return [0] * 10 c1 = read_cpu() time.sleep(0.5) c2 = read_cpu() idle = c2[3] - c1[3] total = sum(c2) - sum(c1) if total > 0: return (1 - idle / total) * 100 return 0.0 @staticmethod def get_memory_info() -> dict: """获取内存信息""" if HAS_PSUTIL: mem = psutil.virtual_memory() return { "total": mem.total, "available": mem.available, "used": mem.used, "free": mem.free, "percent": mem.percent, } # /proc/meminfo 回退 data = _read_file("/proc/meminfo") info = {} if data: for line in data.split("\n"): parts = line.split() if len(parts) >= 2: info[parts[0].rstrip(":")] = int(parts[1]) * 1024 return { "total": info.get("MemTotal", 0), "available": info.get("MemAvailable", 0), "used": info.get("MemTotal", 0) - info.get("MemAvailable", 0), "free": info.get("MemFree", 0), "percent": 0, } # ═══════════════════════════════════════════════════════════════ # 位置 — 真实实现 (IP 地理定位) # ═══════════════════════════════════════════════════════════════ class LinuxLocation: """真实位置信息 — 通过 IP 地理定位""" _cached_location = None _cache_time = 0 @staticmethod def get_location() -> dict: """通过 IP 获取地理位置""" if not HAS_REQUESTS: return {"latitude": 0.0, "longitude": 0.0, "accuracy": 0, "source": "none"} # 缓存 5 分钟 now = time.time() if LinuxLocation._cached_location and now - LinuxLocation._cache_time < 300: return LinuxLocation._cached_location # 尝试多个 IP 定位服务 services = [ ("http://ip-api.com/json/", "ip-api"), ("https://ipapi.co/json/", "ipapi"), ("https://freeipapi.com/api/json", "freeipapi"), ] for url, source in services: try: resp = requests.get(url, timeout=5) if resp.status_code == 200: data = resp.json() lat = data.get("lat") or data.get("latitude") or 0.0 lon = data.get("lon") or data.get("longitude") or 0.0 location = { "latitude": float(lat), "longitude": float(lon), "accuracy": 50000, # IP定位精度约 50km "source": source, "city": data.get("city", ""), "region": data.get("regionName", data.get("region", "")), "country": data.get("country", data.get("country_name", "")), "country_code": data.get("countryCode", data.get("country", "")), "timezone": data.get("timezone", ""), "isp": data.get("isp", ""), } LinuxLocation._cached_location = location LinuxLocation._cache_time = now return location except Exception: continue return {"latitude": 0.0, "longitude": 0.0, "accuracy": 0, "source": "failed"} # ═══════════════════════════════════════════════════════════════ # 通知 — 真实实现 (notify-send) # ═══════════════════════════════════════════════════════════════ class LinuxNotify: """真实桌面通知 — 通过 notify-send""" @staticmethod def send(title: str, text: str, icon: str = None, urgency: str = "normal", timeout: int = -1) -> bool: if not HAS_NOTIFY_SEND: # 回退到 stderr print(f"[{title}] {text}", file=sys.stderr) return False cmd = ["notify-send"] cmd.extend(["-u", urgency]) if timeout >= 0: cmd.extend(["-t", str(timeout)]) if icon: cmd.extend(["-i", icon]) cmd.extend([title, text]) code, _, _ = _run(cmd) return code == 0 # ═══════════════════════════════════════════════════════════════ # 显示/屏幕 — 真实实现 (xdotool, X11) # ═══════════════════════════════════════════════════════════════ class LinuxDisplay: """真实显示/屏幕信息""" @staticmethod def get_screen_size() -> Tuple[int, int]: """获取屏幕尺寸""" if HAS_XDOTOOL: code, out, _ = _run(["xdotool", "getdisplaygeometry"]) if code == 0: parts = out.split() if len(parts) >= 2: return int(parts[0]), int(parts[1]) if HAS_XRANDR: code, out, _ = _run(["xrandr", "--current"]) if code == 0: for line in out.split("\n"): if " connected" in line and "*" in line: match = re.search(r'(\d+)x(\d+)', line) if match: return int(match.group(1)), int(match.group(2)) # 回退 try: import tkinter root = tkinter.Tk() w = root.winfo_screenwidth() h = root.winfo_screenheight() root.destroy() return w, h except Exception: return 1920, 1080 @staticmethod def screenshot(path: str = None) -> Optional[str]: """截屏""" if HAS_PIL: try: img = ImageGrab.grab() if path: img.save(path) return path else: buf = f"/tmp/screenshot_{int(time.time())}.png" img.save(buf) return buf except Exception: pass if HAS_XDOTOOL: # 用 xdotool + import if not path: path = f"/tmp/screenshot_{int(time.time())}.png" code, _, _ = _run(["import", "-window", "root", path]) if code == 0: return path return None @staticmethod def get_mouse_position() -> Tuple[int, int]: """获取鼠标位置""" if HAS_XDOTOOL: code, out, _ = _run(["xdotool", "getmouselocation"]) if code == 0: parts = out.split() x = int(parts[0].split(":")[1]) y = int(parts[1].split(":")[1]) return x, y return 0, 0 # ═══════════════════════════════════════════════════════════════ # UI 自动化 — 真实实现 (xdotool) # ═══════════════════════════════════════════════════════════════ class LinuxUIAutomation: """真实 UI 自动化 — 通过 xdotool""" @staticmethod def click(x: int, y: int) -> bool: if not HAS_XDOTOOL: return False code, _, _ = _run(["xdotool", "mousemove", str(x), str(y), "click", "1"]) return code == 0 @staticmethod def double_click(x: int, y: int) -> bool: if not HAS_XDOTOOL: return False code, _, _ = _run(["xdotool", "mousemove", str(x), str(y), "click", "--repeat", "2", "1"]) return code == 0 @staticmethod def right_click(x: int, y: int) -> bool: if not HAS_XDOTOOL: return False code, _, _ = _run(["xdotool", "mousemove", str(x), str(y), "click", "3"]) return code == 0 @staticmethod def type_text(text: str) -> bool: if not HAS_XDOTOOL: return False code, _, _ = _run(["xdotool", "type", "--clearmodifiers", text]) return code == 0 @staticmethod def press_key(key: str) -> bool: if not HAS_XDOTOOL: return False # 转换常见键名 key_map = { "enter": "Return", "return": "Return", "tab": "Tab", "escape": "Escape", "esc": "Escape", "backspace": "BackSpace", "delete": "Delete", "space": "space", "up": "Up", "down": "Down", "left": "Left", "right": "Right", "home": "Home", "end": "End", "page_up": "Page_Up", "page_down": "Page_Down", } xdotool_key = key_map.get(key.lower(), key) code, _, _ = _run(["xdotool", "key", xdotool_key]) return code == 0 @staticmethod def hotkey(*keys) -> bool: if not HAS_XDOTOOL: return False key_map = { "ctrl": "ctrl", "control": "ctrl", "alt": "alt", "shift": "shift", "super": "super", "win": "super", "meta": "super", } mapped = [key_map.get(k.lower(), k) for k in keys] combo = "+".join(mapped) code, _, _ = _run(["xdotool", "key", combo]) return code == 0 @staticmethod def scroll(amount: int) -> bool: if not HAS_XDOTOOL: return False button = "4" if amount > 0 else "5" count = abs(amount) code, _, _ = _run(["xdotool", "click", "--repeat", str(count), button]) return code == 0 @staticmethod def find_window(title: str) -> Optional[int]: """查找窗口""" if not HAS_XDOTOOL: return None code, out, _ = _run(["xdotool", "search", "--name", title]) if code == 0 and out: return int(out.split("\n")[0]) return None @staticmethod def activate_window(window_id: int) -> bool: if not HAS_XDOTOOL: return False code, _, _ = _run(["xdotool", "windowactivate", str(window_id)]) return code == 0 @staticmethod def close_window(window_id: int) -> bool: if not HAS_XDOTOOL: return False code, _, _ = _run(["xdotool", "windowclose", str(window_id)]) return code == 0 # ═══════════════════════════════════════════════════════════════ # 摄像头 — 真实实现 (opencv/V4L2) # ═══════════════════════════════════════════════════════════════ class LinuxCamera: """真实摄像头 — 通过 OpenCV/V4L2""" @staticmethod def list_cameras() -> List[int]: """列出可用摄像头""" cameras = [] for dev in _list_dir("/dev/"): if dev.startswith("video"): try: idx = int(dev[5:]) cameras.append(idx) except ValueError: pass return cameras @staticmethod def capture(camera_id: int = 0, output_path: str = None) -> Optional[str]: """拍照""" if not HAS_CV2: return None cap = cv2.VideoCapture(camera_id) if not cap.isOpened(): return None ret, frame = cap.read() cap.release() if not ret: return None if not output_path: output_path = f"/tmp/camera_{int(time.time())}.jpg" cv2.imwrite(output_path, frame) return output_path @staticmethod def get_camera_info(camera_id: int = 0) -> dict: """获取摄像头信息""" info = {"id": camera_id, "available": False, "width": 0, "height": 0} if HAS_CV2: cap = cv2.VideoCapture(camera_id) if cap.isOpened(): info["available"] = True info["width"] = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) info["height"] = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) info["fps"] = cap.get(cv2.CAP_PROP_FPS) cap.release() return info # ═══════════════════════════════════════════════════════════════ # 进程/应用管理 — 真实实现 (psutil, /proc) # ═══════════════════════════════════════════════════════════════ class LinuxProcess: """真实进程/应用管理""" @staticmethod def list_processes() -> List[dict]: """列出所有进程""" if HAS_PSUTIL: procs = [] for p in psutil.process_iter(['pid', 'name', 'username', 'memory_percent', 'cpu_percent', 'status', 'cmdline']): try: info = p.info procs.append({ "pid": info['pid'], "name": info['name'], "user": info['username'], "memory": info.get('memory_percent', 0), "cpu": info.get('cpu_percent', 0), "status": info['status'], "cmdline": " ".join(info.get('cmdline') or []), }) except (psutil.NoSuchProcess, psutil.AccessDenied): pass return procs # /proc 回退 procs = [] for name in _list_dir("/proc/"): if name.isdigit(): pid = int(name) comm = _read_file(f"/proc/{name}/comm") or "" cmdline = _read_file(f"/proc/{name}/cmdline") or "" status = _read_file(f"/proc/{name}/status") or "" user = "" for line in status.split("\n"): if line.startswith("Uid:"): uid = line.split()[1] try: import pwd user = pwd.getpwuid(int(uid)).pw_name except Exception: user = uid break procs.append({ "pid": pid, "name": comm, "user": user, "cmdline": cmdline.replace("\x00", " "), "status": "", "memory": 0, "cpu": 0, }) return procs @staticmethod def get_running_apps() -> List[dict]: """获取运行中的应用 (GUI 窗口)""" if not HAS_XDOTOOL: return [] code, out, _ = _run(["xdotool", "search", "--onlyvisible", "--class", ""]) apps = [] if code == 0: seen = set() for wid in out.split("\n"): if wid and wid not in seen: seen.add(wid) # 获取窗口名称 code2, name, _ = _run(["xdotool", "getwindowname", wid]) if name: apps.append({"window_id": int(wid), "name": name}) return apps @staticmethod def kill_process(pid: int) -> bool: """终止进程""" if HAS_PSUTIL: try: psutil.Process(pid).terminate() return True except Exception: return False code, _, _ = _run(["kill", str(pid)]) return code == 0 @staticmethod def start_app(name: str) -> bool: """启动应用""" code, _, _ = _run([name], timeout=1) # 非阻塞启动 try: subprocess.Popen([name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) return True except Exception: return False @staticmethod def get_system_info() -> dict: """获取系统信息""" info = { "platform": platform.system(), "hostname": socket.gethostname(), "kernel": platform.release(), "arch": platform.machine(), "python": platform.python_version(), } if HAS_PSUTIL: info["cpu_count"] = psutil.cpu_count() info["cpu_freq"] = psutil.cpu_freq().current if psutil.cpu_freq() else 0 boot_time = psutil.boot_time() info["uptime"] = time.time() - boot_time mem = psutil.virtual_memory() info["memory_total"] = mem.total info["memory_used"] = mem.used info["memory_percent"] = mem.percent disk = psutil.disk_usage('/') info["disk_total"] = disk.total info["disk_used"] = disk.used info["disk_percent"] = disk.percent return info # ═══════════════════════════════════════════════════════════════ # Shell — 真实实现 # ═══════════════════════════════════════════════════════════════ class LinuxShell: """真实 Shell 执行""" @staticmethod def exec(command: str, timeout: int = 30, root: bool = False) -> dict: """执行 Shell 命令""" if root and os.geteuid() != 0: command = f"sudo {command}" code, out, err = _run(command, timeout=timeout) return { "success": code == 0, "output": out + ("\n" + err if err else ""), "exit_code": code, "stdout": out, "stderr": err, } @staticmethod def exec_async(command: str) -> int: """异步执行命令,返回 PID""" proc = subprocess.Popen(command, shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) return proc.pid # ═══════════════════════════════════════════════════════════════ # 统一入口 # ═══════════════════════════════════════════════════════════════ def get_linux_capabilities() -> dict: """获取当前 Linux 环境的可用能力""" return { "is_linux": IS_LINUX, "network": HAS_IP, "bluetooth": HAS_BLUETOOTHCTL, "audio": HAS_AMIXER, "clipboard": HAS_XCLIP, "ui_automation": HAS_XDOTOOL, "notifications": HAS_NOTIFY_SEND, "sensors": True, # /sys 总是可用 "display": True, "camera": HAS_CV2, "ffmpeg": HAS_FFMPEG, "ffprobe": HAS_FFPROBE, "psutil": HAS_PSUTIL, "requests": HAS_REQUESTS, "PIL": HAS_PIL, "opencv": HAS_CV2, "numpy": HAS_NUMPY, "dbus": HAS_DBUS, "xrandr": HAS_XRANDR, } # 导出所有实现类 __all__ = [ "IS_LINUX", "get_linux_capabilities", "LinuxNetwork", "LinuxBluetooth", "LinuxSensors", "LinuxAudio", "LinuxClipboard", "LinuxPower", "LinuxLocation", "LinuxNotify", "LinuxDisplay", "LinuxUIAutomation", "LinuxCamera", "LinuxProcess", "LinuxShell", # 能力标志 "HAS_IP", "HAS_BLUETOOTHCTL", "HAS_AMIXER", "HAS_XCLIP", "HAS_XDOTOOL", "HAS_NOTIFY_SEND", "HAS_SENSORS", "HAS_FFMPEG", "HAS_FFPROBE", "HAS_PSUTIL", "HAS_REQUESTS", "HAS_CV2", "HAS_NUMPY", "HAS_PIL", "HAS_DBUS", "HAS_XRANDR", ]