✨ feat: implement core functionality and UI for Modus shell
This commit is contained in:
1 parent
5789013e2e
commit
acec6bdda7
129 files changed
+33424
-226
No files matched your search
@@ -0,0 +1,4 @@
|
||||
"""
|
||||
Modus services package.
|
||||
Contains background services and utilities for the shell.
|
||||
"""
|
||||
@@ -0,0 +1,348 @@
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import pyotp
|
||||
from PIL import Image
|
||||
from pyzbar.pyzbar import decode
|
||||
|
||||
import config.data as data
|
||||
|
||||
|
||||
def get_otp_file_path():
|
||||
"""Returns the path to the OTP file inside the cache directory."""
|
||||
cache_dir = Path(data.CACHE_DIR) / "otp"
|
||||
# Create the directory if it doesn't exist
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
file_path = cache_dir / "otp_codes.json"
|
||||
|
||||
# Create the file if it doesn't exist
|
||||
if not file_path.exists():
|
||||
with open(file_path, "w") as f:
|
||||
json.dump([], f)
|
||||
print(f"File {file_path} created successfully!")
|
||||
|
||||
return file_path
|
||||
|
||||
|
||||
def capture_selected_area(filename="/tmp/screenshot.png"):
|
||||
"""
|
||||
Uses slurp to let the user select an area of the screen,
|
||||
then captures that area using grim.
|
||||
"""
|
||||
try:
|
||||
# slurp returns coordinates in format: x,y widthxheight (e.g. 100,200 300x400)
|
||||
result = subprocess.run(["slurp"], check=True, capture_output=True, text=True)
|
||||
geometry = result.stdout.strip()
|
||||
if not geometry:
|
||||
print("No area selected with slurp.")
|
||||
return None
|
||||
except subprocess.CalledProcessError as e:
|
||||
print("Error selecting area with slurp:", e)
|
||||
return None
|
||||
|
||||
try:
|
||||
# grim uses -g to capture a specific region
|
||||
subprocess.run(["grim", "-g", geometry, filename], check=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print("Error capturing screenshot with grim:", e)
|
||||
return None
|
||||
|
||||
return filename
|
||||
|
||||
|
||||
def read_and_save_to_json():
|
||||
# Get the full path to the JSON file
|
||||
json_file = get_otp_file_path()
|
||||
|
||||
screenshot = capture_selected_area()
|
||||
if screenshot is None:
|
||||
print("Failed to capture the selected area.")
|
||||
return False
|
||||
|
||||
# Short delay to ensure the file is written
|
||||
time.sleep(1)
|
||||
|
||||
# Open the captured image
|
||||
try:
|
||||
img = Image.open(screenshot)
|
||||
except Exception as e:
|
||||
print("Error opening the image:", e)
|
||||
return False
|
||||
|
||||
# Decode QR Code(s) from the image
|
||||
decoded_objects = decode(img)
|
||||
if not decoded_objects:
|
||||
print("No QR Code detected in the selected area.")
|
||||
return False
|
||||
|
||||
results = []
|
||||
|
||||
for obj in decoded_objects:
|
||||
data = obj.data.decode("utf-8")
|
||||
print("QR Code detected:", data)
|
||||
|
||||
result_entry = {"timestamp": datetime.now().isoformat(), "qr_data": data}
|
||||
|
||||
if data.startswith("otpauth://"):
|
||||
parsed = urlparse(data)
|
||||
query = parse_qs(parsed.query)
|
||||
|
||||
# Extract the secret properly
|
||||
secret = query.get("secret", [None])[0]
|
||||
issuer_from_query = query.get("issuer", [None])[0]
|
||||
|
||||
# Extract the label (path), which may contain issuer and account
|
||||
label = parsed.path.lstrip("/") if parsed.path else ""
|
||||
account_name = label
|
||||
issuer_from_path = None
|
||||
|
||||
if ":" in label:
|
||||
parts = label.split(":", 1)
|
||||
issuer_from_path = parts[0]
|
||||
account_name = parts[1]
|
||||
|
||||
# Prefer issuer from path if available, otherwise use from query
|
||||
issuer = issuer_from_path or issuer_from_query
|
||||
|
||||
# Extract the period (default is 30 seconds)
|
||||
period = int(query.get("period", ["30"])[0])
|
||||
|
||||
# Create TOTP object with the correct interval
|
||||
totp = pyotp.TOTP(secret, interval=period)
|
||||
current_otp = totp.now()
|
||||
print(f"Generated OTP: {current_otp} (valid for {period} seconds)")
|
||||
|
||||
result_entry.update(
|
||||
{
|
||||
"type": "otp",
|
||||
"secret": secret,
|
||||
"issuer": issuer,
|
||||
"account_name": account_name,
|
||||
}
|
||||
)
|
||||
else:
|
||||
result_entry["type"] = "unknown"
|
||||
print("Unrecognized format. Expected a URI like otpauth://")
|
||||
return False
|
||||
|
||||
results.append(result_entry)
|
||||
|
||||
# Load existing data if the file exists
|
||||
existing_data = []
|
||||
if os.path.exists(json_file):
|
||||
try:
|
||||
with open(json_file, "r") as f:
|
||||
existing_data = json.load(f)
|
||||
except json.JSONDecodeError:
|
||||
print(f"Error reading the file {json_file}. Creating a new one.")
|
||||
|
||||
# Append new results
|
||||
existing_data.extend(results)
|
||||
|
||||
# Save to JSON file
|
||||
with open(json_file, "w") as f:
|
||||
json.dump(existing_data, f, indent=4)
|
||||
|
||||
print(f"OTP data saved to {json_file}")
|
||||
return True
|
||||
|
||||
|
||||
def CodeOTP(uri):
|
||||
parsed = urlparse(uri)
|
||||
query = parse_qs(parsed.query)
|
||||
secret = query.get("secret", [None])[0]
|
||||
|
||||
if secret is None:
|
||||
return None
|
||||
else:
|
||||
totp = pyotp.TOTP(secret)
|
||||
return totp.now()
|
||||
|
||||
|
||||
# TOTP/OTP utility functions
|
||||
def generate_totp(secret: str) -> str:
|
||||
"""Generate TOTP code from secret."""
|
||||
try:
|
||||
return pyotp.TOTP(secret).now()
|
||||
except Exception as e:
|
||||
print(f"Error generating TOTP: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_time_remaining() -> int:
|
||||
"""Get seconds remaining until next token refresh."""
|
||||
return 30 - (int(time.time()) % 30)
|
||||
|
||||
|
||||
def get_time_remaining_with_blink() -> str:
|
||||
"""Get time remaining with blinking effect."""
|
||||
time_remaining = get_time_remaining()
|
||||
current_second = int(time.time())
|
||||
should_blink = current_second % 2 == 0
|
||||
|
||||
if should_blink:
|
||||
return f"<span alpha='30%'>{time_remaining}s</span>"
|
||||
else:
|
||||
return f"{time_remaining}s"
|
||||
|
||||
|
||||
def validate_base32_secret(secret: str) -> dict:
|
||||
"""Validate and clean Base32 secret."""
|
||||
import base64
|
||||
import re
|
||||
|
||||
try:
|
||||
# Clean up the secret - remove spaces, dashes, and convert to uppercase
|
||||
clean_secret = secret.replace(" ", "").replace("-", "").replace("_", "").upper()
|
||||
|
||||
# Remove any non-base32 characters
|
||||
clean_secret = re.sub(r"[^A-Z2-7]", "", clean_secret)
|
||||
|
||||
# Add padding if needed (Base32 requires padding to multiple of 8)
|
||||
while len(clean_secret) % 8 != 0:
|
||||
clean_secret += "="
|
||||
|
||||
# Validate Base32 format
|
||||
try:
|
||||
base64.b32decode(clean_secret)
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Invalid Base32 secret: {str(e)}"}
|
||||
|
||||
# Test if the secret can generate a valid TOTP
|
||||
try:
|
||||
test_totp = pyotp.TOTP(clean_secret)
|
||||
test_code = test_totp.now()
|
||||
if not test_code or len(test_code) != 6:
|
||||
raise ValueError("Generated invalid TOTP code")
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Cannot generate TOTP: {str(e)}"}
|
||||
|
||||
return {"success": True, "secret": clean_secret}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Unexpected error: {str(e)}"}
|
||||
|
||||
|
||||
def parse_otpauth_uri(uri: str, account_name: str = "") -> dict:
|
||||
"""Parse otpauth URI and extract account information."""
|
||||
try:
|
||||
parsed = urlparse(uri)
|
||||
if parsed.scheme != "otpauth" or parsed.netloc != "totp":
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Only otpauth://totp/ URIs are supported",
|
||||
}
|
||||
|
||||
if not account_name:
|
||||
account_path = parsed.path.lstrip("/")
|
||||
if ":" in account_path:
|
||||
issuer, extracted_name = account_path.split(":", 1)
|
||||
account_name = extracted_name
|
||||
else:
|
||||
account_name = account_path
|
||||
|
||||
params = parse_qs(parsed.query)
|
||||
secret = params.get("secret", [""])[0]
|
||||
issuer = params.get("issuer", [""])[0]
|
||||
algorithm = params.get("algorithm", ["SHA1"])[0]
|
||||
digits = int(params.get("digits", ["6"])[0])
|
||||
period = int(params.get("period", ["30"])[0])
|
||||
|
||||
if not secret:
|
||||
return {"success": False, "error": "No secret found in URI"}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"account_name": account_name,
|
||||
"secret": secret,
|
||||
"issuer": issuer,
|
||||
"algorithm": algorithm,
|
||||
"digits": digits,
|
||||
"period": period,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Error parsing otpauth URI: {str(e)}"}
|
||||
|
||||
|
||||
def scan_qr_and_add_account(account_name: str, secrets_file_path: str) -> dict:
|
||||
"""Scan QR code and add OTP account to secrets file."""
|
||||
try:
|
||||
# Capture QR code from screen
|
||||
screenshot_path = capture_selected_area()
|
||||
if not screenshot_path:
|
||||
return {"success": False, "error": "QR scan cancelled or failed"}
|
||||
|
||||
# Decode QR code
|
||||
try:
|
||||
img = Image.open(screenshot_path)
|
||||
decoded_objects = decode(img)
|
||||
|
||||
if not decoded_objects:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "No QR code detected in selected area",
|
||||
}
|
||||
|
||||
# Process the first QR code found
|
||||
qr_data = decoded_objects[0].data.decode("utf-8")
|
||||
print(f"QR Code detected: {qr_data}")
|
||||
|
||||
if qr_data.startswith("otpauth://"):
|
||||
# Parse otpauth URI
|
||||
result = parse_otpauth_uri(qr_data, account_name)
|
||||
if not result["success"]:
|
||||
return result
|
||||
|
||||
# Load existing secrets
|
||||
secrets = {}
|
||||
if os.path.exists(secrets_file_path):
|
||||
try:
|
||||
with open(secrets_file_path, "r", encoding="utf-8") as f:
|
||||
secrets = json.load(f)
|
||||
except Exception as e:
|
||||
print(f"Error loading secrets: {e}")
|
||||
|
||||
# Add new account
|
||||
secrets[result["account_name"]] = {
|
||||
"secret": result["secret"],
|
||||
"issuer": result["issuer"],
|
||||
"algorithm": result["algorithm"],
|
||||
"digits": result["digits"],
|
||||
"period": result["period"],
|
||||
}
|
||||
|
||||
# Save secrets
|
||||
try:
|
||||
os.makedirs(os.path.dirname(secrets_file_path), exist_ok=True)
|
||||
with open(secrets_file_path, "w", encoding="utf-8") as f:
|
||||
json.dump(secrets, f, indent=2)
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Error saving secrets: {str(e)}",
|
||||
}
|
||||
|
||||
display_name = (
|
||||
f"{result['issuer']} - {result['account_name']}"
|
||||
if result["issuer"]
|
||||
else result["account_name"]
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"account_name": result["account_name"],
|
||||
"display_name": display_name,
|
||||
"message": f"Successfully added OTP account: {display_name}",
|
||||
}
|
||||
else:
|
||||
return {"success": False, "error": "QR code is not an otpauth URI"}
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Error processing QR code: {str(e)}"}
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Error during QR scan: {str(e)}"}
|
||||
@@ -0,0 +1,169 @@
|
||||
from fabric.core import Property, Service, Signal
|
||||
from pydbus import SystemBus
|
||||
import psutil
|
||||
from gi.repository import GLib
|
||||
|
||||
DeviceState = {
|
||||
0: "UNKNOWN",
|
||||
1: "CHARGING",
|
||||
2: "DISCHARGING",
|
||||
3: "EMPTY",
|
||||
4: "FULLY_CHARGED",
|
||||
5: "PENDING_CHARGE",
|
||||
6: "PENDING_DISCHARGE",
|
||||
}
|
||||
|
||||
|
||||
class Battery(Service):
|
||||
@staticmethod
|
||||
def seconds_to_hours_minutes(seconds):
|
||||
hours = seconds // 3600
|
||||
minutes = (seconds % 3600) // 60
|
||||
return f"{hours}h {minutes}m" if hours else f"{minutes}m"
|
||||
|
||||
@Signal
|
||||
def changed(self) -> None: ...
|
||||
|
||||
@Signal
|
||||
def profile_changed(self, value: str) -> None: ...
|
||||
|
||||
@Property(int, "readable")
|
||||
def percentage(self):
|
||||
if self._use_psutil_fallback:
|
||||
if self._psutil_battery:
|
||||
return int(self._psutil_battery.percent)
|
||||
return 0
|
||||
return int(self._battery.Percentage)
|
||||
|
||||
@Property(str, "readable")
|
||||
def temperature(self):
|
||||
if self._use_psutil_fallback:
|
||||
return "N/A" # psutil doesn't provide temperature
|
||||
return (
|
||||
f"{self._battery.Temperature}°C"
|
||||
if hasattr(self._battery, "Temperature")
|
||||
else "N/A"
|
||||
)
|
||||
|
||||
@Property(str, "readable")
|
||||
def time_to_empty(self):
|
||||
if self._use_psutil_fallback:
|
||||
if self._psutil_battery and hasattr(self._psutil_battery, 'secsleft'):
|
||||
return self.seconds_to_hours_minutes(self._psutil_battery.secsleft)
|
||||
return "N/A"
|
||||
return self.seconds_to_hours_minutes(getattr(self._battery, "TimeToEmpty", 0))
|
||||
|
||||
@Property(str, "readable")
|
||||
def time_to_full(self):
|
||||
if self._use_psutil_fallback:
|
||||
return "N/A" # psutil doesn't provide time to full
|
||||
return self.seconds_to_hours_minutes(getattr(self._battery, "TimeToFull", 0))
|
||||
|
||||
@Property(str, "readable")
|
||||
def icon_name(self):
|
||||
if self._use_psutil_fallback:
|
||||
return "battery" # Generic icon name for psutil fallback
|
||||
return self._battery.IconName
|
||||
|
||||
@Property(str, "readable")
|
||||
def state(self):
|
||||
if self._use_psutil_fallback:
|
||||
if self._psutil_battery:
|
||||
# psutil returns power_plugged boolean, convert to state
|
||||
if self._psutil_battery.power_plugged:
|
||||
if self._psutil_battery.percent >= 100:
|
||||
return "FULLY_CHARGED"
|
||||
else:
|
||||
return "CHARGING"
|
||||
else:
|
||||
return "DISCHARGING"
|
||||
return "UNKNOWN"
|
||||
return DeviceState.get(self._battery.State, "UNKNOWN")
|
||||
|
||||
@Property(str, "readable")
|
||||
def capacity(self):
|
||||
if self._use_psutil_fallback:
|
||||
return "N/A" # psutil doesn't provide capacity info
|
||||
return f"{int(self._battery.Capacity)}%"
|
||||
|
||||
@Property(bool, "readable", default_value=False)
|
||||
def is_present(self):
|
||||
if self._use_psutil_fallback:
|
||||
return self._psutil_battery is not None
|
||||
return self._battery.IsPresent
|
||||
|
||||
@Property(str, "readable")
|
||||
def power_profile(self):
|
||||
if self._profile_proxy:
|
||||
return self._profile_proxy.ActiveProfile
|
||||
return None
|
||||
|
||||
def set_power_profile(self, profile: str) -> bool:
|
||||
if not self._profile_proxy:
|
||||
return False
|
||||
if profile not in [p.Profile for p in self._profile_proxy.Profiles]:
|
||||
return False
|
||||
try:
|
||||
self._profile_proxy.ActiveProfile = profile
|
||||
self.profile_changed.emit(profile)
|
||||
self.changed.emit()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._bus = SystemBus()
|
||||
self._use_psutil_fallback = False
|
||||
self._psutil_battery = None
|
||||
|
||||
# Battery device
|
||||
try:
|
||||
self._battery = self._bus.get(
|
||||
"org.freedesktop.UPower", "/org/freedesktop/UPower/devices/battery_BAT0"
|
||||
)
|
||||
self._battery.onPropertiesChanged = self.handle_battery_change
|
||||
except Exception:
|
||||
# Fallback to psutil if UPower is not available
|
||||
self._use_psutil_fallback = True
|
||||
try:
|
||||
self._psutil_battery = psutil.sensors_battery()
|
||||
if self._psutil_battery is None:
|
||||
return # No battery found
|
||||
# Start periodic updates for psutil fallback
|
||||
GLib.timeout_add_seconds(5, self._update_psutil_battery)
|
||||
except Exception:
|
||||
return # psutil battery not available either
|
||||
|
||||
# PowerProfiles
|
||||
try:
|
||||
self._profile_proxy = self._bus.get(
|
||||
"net.hadess.PowerProfiles", "/net/hadess/PowerProfiles"
|
||||
)
|
||||
# Use onPropertiesChanged for consistency with battery device
|
||||
self._profile_proxy.onPropertiesChanged = (
|
||||
lambda _, changed, __: self._handle_profile_props_changed(changed)
|
||||
)
|
||||
except Exception:
|
||||
self._profile_proxy = None
|
||||
|
||||
self.changed.emit()
|
||||
|
||||
def _update_psutil_battery(self):
|
||||
"""Update psutil battery data periodically"""
|
||||
try:
|
||||
self._psutil_battery = psutil.sensors_battery()
|
||||
self.changed.emit()
|
||||
except Exception:
|
||||
pass # Continue trying
|
||||
return True # Keep the timeout active
|
||||
|
||||
def _handle_profile_props_changed(self, changed):
|
||||
"""Internal handler for property changes that processes only the changed properties"""
|
||||
if "ActiveProfile" in changed:
|
||||
new_profile = changed["ActiveProfile"]
|
||||
self.profile_changed.emit(new_profile)
|
||||
self.changed.emit()
|
||||
|
||||
def handle_battery_change(self, iface, changed, invalidated):
|
||||
self.changed.emit()
|
||||
@@ -0,0 +1,95 @@
|
||||
import os
|
||||
|
||||
from fabric.core.service import Property, Service, Signal
|
||||
from fabric.utils import exec_shell_command_async, monitor_file
|
||||
from gi.repository import GLib
|
||||
from loguru import logger
|
||||
|
||||
|
||||
def exec_brightnessctl_async(args: str):
|
||||
exec_shell_command_async(f"brightnessctl {args}", lambda _: None)
|
||||
|
||||
|
||||
# Discover screen backlight device
|
||||
try:
|
||||
screen_device = os.listdir("/sys/class/backlight")
|
||||
screen_device = screen_device[0] if screen_device else ""
|
||||
except FileNotFoundError:
|
||||
logger.error("No backlight devices found, brightness control disabled")
|
||||
screen_device = ""
|
||||
|
||||
|
||||
class Brightness(Service):
|
||||
"""Service to manage screen brightness levels."""
|
||||
|
||||
instance = None
|
||||
|
||||
@staticmethod
|
||||
def get_initial():
|
||||
if Brightness.instance is None:
|
||||
Brightness.instance = Brightness()
|
||||
|
||||
return Brightness.instance
|
||||
|
||||
@Signal
|
||||
def screen(self, value: int) -> None:
|
||||
"""Signal emitted when screen brightness changes."""
|
||||
# Implement as needed for your application
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
|
||||
# Path for screen backlight control
|
||||
self.screen_backlight_path = f"/sys/class/backlight/{screen_device}"
|
||||
|
||||
# Initialize maximum brightness level
|
||||
self.max_screen = self.do_read_max_brightness(self.screen_backlight_path)
|
||||
|
||||
if screen_device == "":
|
||||
return
|
||||
|
||||
# Monitor screen brightness file
|
||||
self.screen_monitor = monitor_file(f"{self.screen_backlight_path}/brightness")
|
||||
|
||||
self.screen_monitor.connect(
|
||||
"changed",
|
||||
lambda _, file, *args: self.emit(
|
||||
"screen",
|
||||
round(int(file.load_bytes()[0].get_data())),
|
||||
),
|
||||
)
|
||||
|
||||
# Log the initialization of the service
|
||||
logger.info(f"Brightness service initialized for device: {screen_device}")
|
||||
|
||||
def do_read_max_brightness(self, path: str) -> int:
|
||||
# Reads the maximum brightness value from the specified path.
|
||||
max_brightness_path = os.path.join(path, "max_brightness")
|
||||
if os.path.exists(max_brightness_path):
|
||||
with open(max_brightness_path) as f:
|
||||
return int(f.readline())
|
||||
return -1 # Return -1 if file doesn't exist, indicating an error.
|
||||
|
||||
@Property(int, "read-write")
|
||||
def screen_brightness(self) -> int:
|
||||
# Property to get or set the screen brightness.
|
||||
brightness_path = os.path.join(self.screen_backlight_path, "brightness")
|
||||
if os.path.exists(brightness_path):
|
||||
with open(brightness_path) as f:
|
||||
return int(f.readline())
|
||||
logger.warning(f"Brightness file does not exist: {brightness_path}")
|
||||
return -1 # Return -1 if file doesn't exist, indicating error.
|
||||
|
||||
@screen_brightness.setter
|
||||
def screen_brightness(self, value: int):
|
||||
# Setter for screen brightness property.
|
||||
if not (0 <= value <= self.max_screen):
|
||||
value = max(0, min(value, self.max_screen))
|
||||
|
||||
try:
|
||||
exec_brightnessctl_async(f"--device '{screen_device}' set {value}")
|
||||
self.emit("screen", int((value / self.max_screen) * 100))
|
||||
except GLib.Error as e:
|
||||
logger.error(f"Error setting screen brightness: {e.message}")
|
||||
except Exception as e:
|
||||
logger.exception(f"Unexpected error setting screen brightness: {e}")
|
||||
@@ -0,0 +1,244 @@
|
||||
from gi.repository import Playerctl, GLib
|
||||
from fabric.utils import bulk_connect
|
||||
from fabric.core.service import Service, Signal
|
||||
from fabric import Fabricator
|
||||
|
||||
from config.data import ALLOWED_PLAYERS
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Playerctl", "2.0")
|
||||
|
||||
|
||||
class PlayerService(Service):
|
||||
@Signal
|
||||
def shuffle_toggle(self, player: Playerctl.Player, status: bool) -> None: ...
|
||||
|
||||
@Signal
|
||||
def meta_change(self, metadata: GLib.Variant, player: Playerctl.Player) -> None: ...
|
||||
|
||||
@Signal
|
||||
def pause(self) -> None: ...
|
||||
|
||||
@Signal
|
||||
def play(self) -> None: ...
|
||||
|
||||
@Signal
|
||||
def track_position(self, pos: float, dur: float) -> None: ...
|
||||
|
||||
def __init__(self, player: Playerctl.Player, **kwargs):
|
||||
self._player: Playerctl.Player = player
|
||||
super().__init__(**kwargs)
|
||||
if player.props.player_name in ALLOWED_PLAYERS:
|
||||
self._player.connect("playback-status::playing", self.on_play)
|
||||
self._player.connect("playback-status::paused", self.on_pause)
|
||||
self._player.connect("shuffle", self.on_shuffle)
|
||||
self._player.connect("metadata", self.on_metadata)
|
||||
self._player.connect("seeked", self.on_seeked)
|
||||
|
||||
self.status = self._player.props.playback_status
|
||||
self.pos_fabricator = Fabricator(
|
||||
interval=1000, # 1s
|
||||
poll_from=lambda f, *_: self._safe_get_position(),
|
||||
on_changed=lambda f, *_: self.fabricating(),
|
||||
)
|
||||
# Add state tracking to prevent repeated operations
|
||||
self._fabricator_running = False
|
||||
self._cleanup_done = False
|
||||
self.poll_progress()
|
||||
|
||||
def _safe_get_position(self):
|
||||
"""Safely get player position with error handling"""
|
||||
try:
|
||||
if not self._player or self._cleanup_done:
|
||||
return 0
|
||||
return self._player.get_position()
|
||||
except Exception:
|
||||
# Player is no longer available, stop the fabricator
|
||||
if hasattr(self, 'pos_fabricator') and self.pos_fabricator and self._fabricator_running:
|
||||
try:
|
||||
self.pos_fabricator.stop()
|
||||
self._fabricator_running = False
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up resources when service is no longer needed"""
|
||||
if self._cleanup_done:
|
||||
return # Already cleaned up
|
||||
|
||||
try:
|
||||
if hasattr(self, 'pos_fabricator') and self.pos_fabricator:
|
||||
if self._fabricator_running:
|
||||
self.pos_fabricator.stop()
|
||||
self._fabricator_running = False
|
||||
self.pos_fabricator = None
|
||||
self._cleanup_done = True
|
||||
except Exception as e:
|
||||
print(f"[DEBUG] Error during PlayerService cleanup: {e}")
|
||||
|
||||
def on_seeked(self, player, position):
|
||||
if (not self._cleanup_done and
|
||||
self.status.value_name == "PLAYERCTL_PLAYBACK_STATUS_PLAYING" and
|
||||
not self._fabricator_running):
|
||||
try:
|
||||
self.pos_fabricator.start()
|
||||
self._fabricator_running = True
|
||||
except Exception:
|
||||
pass # Ignore if already started
|
||||
|
||||
def set_position(self, pos: float):
|
||||
if not self._cleanup_done and self._fabricator_running:
|
||||
try:
|
||||
self.pos_fabricator.stop()
|
||||
self._fabricator_running = False
|
||||
except Exception:
|
||||
pass # Ignore if already stopped
|
||||
|
||||
micro_pos = int(pos * 1_000_000)
|
||||
try:
|
||||
self._player.set_position(micro_pos)
|
||||
except GLib.Error as e:
|
||||
print(f"Failed to seek: {e}")
|
||||
|
||||
def poll_progress(self):
|
||||
if self._cleanup_done:
|
||||
return # Don't operate on cleaned up fabricator
|
||||
|
||||
if self.status.value_name == "PLAYERCTL_PLAYBACK_STATUS_PLAYING":
|
||||
if not self._fabricator_running:
|
||||
try:
|
||||
self.pos_fabricator.start()
|
||||
self._fabricator_running = True
|
||||
except Exception:
|
||||
pass # Ignore if already started
|
||||
else:
|
||||
if self._fabricator_running:
|
||||
try:
|
||||
self.pos_fabricator.stop()
|
||||
self._fabricator_running = False
|
||||
except Exception:
|
||||
pass # Ignore if already stopped
|
||||
|
||||
def fabricating(self):
|
||||
try:
|
||||
# Check if cleanup has been done
|
||||
if self._cleanup_done:
|
||||
return
|
||||
|
||||
# Validate player object
|
||||
if not self._player or not hasattr(self._player, 'get_position'):
|
||||
return
|
||||
|
||||
# Get position safely
|
||||
try:
|
||||
pos = self._player.get_position() / 1_000_000 # seconds
|
||||
except Exception:
|
||||
# Player is no longer available, stop fabricator
|
||||
if self._fabricator_running:
|
||||
try:
|
||||
self.pos_fabricator.stop()
|
||||
self._fabricator_running = False
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
# Get duration safely from multiple sources
|
||||
dur = 0.0 # Default to 0
|
||||
|
||||
if (hasattr(self._player, 'props') and
|
||||
hasattr(self._player.props, 'metadata') and
|
||||
self._player.props.metadata):
|
||||
|
||||
metadata = self._player.props.metadata
|
||||
|
||||
# Try mpris:length first (most common)
|
||||
try:
|
||||
raw_dur = metadata["mpris:length"]
|
||||
if raw_dur and raw_dur > 0:
|
||||
dur = float(raw_dur) / 1_000_000 # seconds
|
||||
except (KeyError, Exception):
|
||||
pass
|
||||
|
||||
# Fallback to xesam:duration (alternative field)
|
||||
if dur <= 0:
|
||||
try:
|
||||
raw_dur = metadata["xesam:duration"]
|
||||
if raw_dur and raw_dur > 0:
|
||||
dur = float(raw_dur) / 1_000_000 # seconds
|
||||
except (KeyError, Exception):
|
||||
pass
|
||||
|
||||
# Try to get duration from player directly (some players support this)
|
||||
if dur <= 0:
|
||||
try:
|
||||
if hasattr(self._player, 'get_metadata'):
|
||||
player_metadata = self._player.get_metadata()
|
||||
if player_metadata:
|
||||
raw_dur = player_metadata["mpris:length"]
|
||||
if raw_dur and raw_dur > 0:
|
||||
dur = float(raw_dur) / 1_000_000
|
||||
except (KeyError, Exception):
|
||||
pass
|
||||
|
||||
# Validate values before emitting signal
|
||||
# Allow dur=0 for position-only tracking
|
||||
if pos is not None and dur is not None and pos >= 0 and dur >= 0:
|
||||
self.track_position(pos, dur)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def on_play(self, player, status):
|
||||
self.status = player.props.playback_status
|
||||
self.poll_progress()
|
||||
self.play()
|
||||
|
||||
def on_pause(self, player, status):
|
||||
self.poll_progress()
|
||||
self.pause()
|
||||
|
||||
def on_shuffle(self, player, status):
|
||||
self.shuffle_toggle(player, status)
|
||||
|
||||
def on_metadata(self, player, metadata):
|
||||
keys = metadata.keys()
|
||||
self.meta_change(metadata, player)
|
||||
|
||||
|
||||
class PlayerManager(Service):
|
||||
@Signal
|
||||
def new_player(self, player: Playerctl.Player) -> Playerctl.Player: ...
|
||||
|
||||
@Signal
|
||||
def player_vanish(self, player: Playerctl.Player) -> None: ...
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._manager = Playerctl.PlayerManager()
|
||||
self._manager.connect("name-appeared", self._on_name_appeared, self._manager)
|
||||
self._manager.connect(
|
||||
"player-vanished", self._on_player_vanished, self._manager
|
||||
)
|
||||
|
||||
self._players = {}
|
||||
|
||||
def init_all_players(self):
|
||||
# invoked in the UI
|
||||
for player_obj in self._manager.props.player_names:
|
||||
name_str = player_obj.name
|
||||
if name_str in ALLOWED_PLAYERS:
|
||||
player = Playerctl.Player.new_from_name(player_obj)
|
||||
self._manager.manage_player(player)
|
||||
self.new_player(player)
|
||||
|
||||
def _on_name_appeared(self, sender, name, manager):
|
||||
name_str = name.name
|
||||
if name_str in ALLOWED_PLAYERS:
|
||||
player = Playerctl.Player.new_from_name(name)
|
||||
self._manager.manage_player(player)
|
||||
self.new_player(player)
|
||||
|
||||
def _on_player_vanished(self, sender, player, manager):
|
||||
self.player_vanish(player)
|
||||
@@ -0,0 +1,631 @@
|
||||
from typing import Optional
|
||||
|
||||
import gi
|
||||
from fabric.core.service import Property, Service, Signal
|
||||
from fabric.utils import bulk_connect, get_enum_member_name, snake_case_to_kebab_case
|
||||
from gi.repository import NM, GLib
|
||||
from loguru import logger
|
||||
|
||||
gi.require_version("NM", "1.0") # Ensure the correct version is loaded
|
||||
|
||||
|
||||
class NetworkClient(Service):
|
||||
"""A service to manage network devices"""
|
||||
|
||||
@Signal
|
||||
def wifi_device_added(self) -> None: ...
|
||||
|
||||
@Signal
|
||||
def ethernet_device_added(self) -> None: ...
|
||||
|
||||
@Signal
|
||||
def wifi_device_removed(self) -> None: ...
|
||||
|
||||
@Signal
|
||||
def ethernet_device_removed(self) -> None: ...
|
||||
|
||||
@Signal
|
||||
def changed(self) -> None: ...
|
||||
|
||||
@Signal
|
||||
def ready(self) -> None: ...
|
||||
|
||||
@Property(bool, "readable", default_value=False)
|
||||
def is_ready(self) -> bool:
|
||||
"""Returns whether the client is ready to use."""
|
||||
return self._client is not None
|
||||
|
||||
@Property(list, "readable")
|
||||
def connections(self) -> Optional[list]:
|
||||
"""Returns the active connections, if available."""
|
||||
return self._client.get_property("connections") if self._client else None
|
||||
|
||||
@Property(object, "readable")
|
||||
def wifi_device(self) -> Optional[object]:
|
||||
"""Returns the WiFi device if available."""
|
||||
return self._wifi_device
|
||||
|
||||
@Property(object, "readable")
|
||||
def ethernet_device(self) -> Optional[object]:
|
||||
"""Returns the Ethernet device if available."""
|
||||
return self._ethernet_device
|
||||
|
||||
@Property(str, "readable")
|
||||
def primary_connection(self) -> Optional[str]:
|
||||
"""Returns the primary connection if available."""
|
||||
return self._client.get_property("primary_connection") if self._client else None
|
||||
|
||||
@Property(str, "readable")
|
||||
def active_connection(self) -> Optional[str]:
|
||||
"""Returns the active connection if available."""
|
||||
return self._client.get_property("active_connection") if self._client else None
|
||||
|
||||
@Property(str, "readable")
|
||||
def state(self) -> str:
|
||||
"""Returns the current network state."""
|
||||
if not self._client:
|
||||
return "disconnected"
|
||||
return snake_case_to_kebab_case(
|
||||
get_enum_member_name(
|
||||
self._client.get_property("state"), default="disconnected"
|
||||
)
|
||||
)
|
||||
|
||||
@Property(str, "readable")
|
||||
def connectivity(self) -> str:
|
||||
"""Returns the connectivity state."""
|
||||
if not self._client:
|
||||
return "disconnected"
|
||||
return snake_case_to_kebab_case(
|
||||
get_enum_member_name(
|
||||
self._client.get_property("connectivity"), default="disconnected"
|
||||
)
|
||||
)
|
||||
|
||||
@Property(list, "readable")
|
||||
def devices(self) -> Optional[list]:
|
||||
"""Returns the list of network devices if available."""
|
||||
return self._client.get_property("devices") if self._client else None
|
||||
|
||||
@Property(str, "readable")
|
||||
def hostname(self) -> Optional[str]:
|
||||
"""Returns the hostname if available."""
|
||||
return self._client.get_property("hostname") if self._client else None
|
||||
|
||||
@Property(bool, "read-write", default_value=False)
|
||||
def networking_enabled(self) -> bool:
|
||||
"""Checks if networking is enabled."""
|
||||
return (
|
||||
self._client.get_property("networking_enabled") if self._client else False
|
||||
)
|
||||
|
||||
@networking_enabled.setter
|
||||
def networking_enabled(self, value: bool):
|
||||
"""Sets the networking state if the client is available."""
|
||||
if self._client:
|
||||
self._client.set_property("networking_enabled", value)
|
||||
|
||||
@Property(bool, "read-write", default_value=False)
|
||||
def wireless_enabled(self) -> bool:
|
||||
"""Checks if wireless networking is enabled."""
|
||||
return self._client.get_property("wireless_enabled") if self._client else False
|
||||
|
||||
@wireless_enabled.setter
|
||||
def wireless_enabled(self, value: bool):
|
||||
"""Sets the wireless networking state if the client is available."""
|
||||
if self._client:
|
||||
self._client.set_property("wireless_enabled", value)
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
|
||||
self._client: NM.Client = None
|
||||
self._wifi_device: Wifi | None = None
|
||||
self._ethernet_device: Ethernet | None = None
|
||||
|
||||
logger.info("[Network] Initializing client asynchronously...")
|
||||
|
||||
# Start async NM client initialization
|
||||
NM.Client.new_async(None, self.on_client_ready)
|
||||
|
||||
def on_client_ready(self, source, result):
|
||||
"""Callback when NM.Client is ready."""
|
||||
try:
|
||||
self._client = NM.Client.new_finish(result) # Retrieve client instance
|
||||
logger.info("[Network] NM.Client initialized successfully!")
|
||||
|
||||
# Connect signals
|
||||
bulk_connect(
|
||||
self._client,
|
||||
{
|
||||
"device-added": lambda _, device: self.on_device_added(
|
||||
device=device
|
||||
),
|
||||
"device-removed": lambda _, device: self.on_device_removed(
|
||||
device=device
|
||||
),
|
||||
"notify::state": lambda *args: self.notifier("state"),
|
||||
"notify::networking-enabled": lambda *args: self.notifier(
|
||||
"networking-enabled"
|
||||
),
|
||||
"notify::wireless-enabled": lambda *args: self.notifier(
|
||||
"wireless-enabled"
|
||||
),
|
||||
# "notify::primary-connection": lambda *args: self.notifier('primary-connection'),
|
||||
# "notify::active-connection": lambda *args: self.notifier('active-connection'),
|
||||
# "active-connection-added": lambda *args: self.emit("changed"),
|
||||
# "active-connection-removed": lambda *args: self.emit("changed")
|
||||
},
|
||||
)
|
||||
|
||||
# Process devices AFTER client is ready
|
||||
for device in self.do_get_raw_devices():
|
||||
self.on_device_added(device=device)
|
||||
|
||||
self.notify("state")
|
||||
self.notify("networking-enabled")
|
||||
self.notify("wireless-enabled")
|
||||
self.notify("is-ready")
|
||||
self.ready.emit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Network] Error initializing NM.Client: {e}")
|
||||
|
||||
def do_get_raw_devices(self) -> list[NM.Device]:
|
||||
return [
|
||||
dev
|
||||
for dev in self.devices
|
||||
if dev.get_device_type() in (NM.DeviceType.WIFI, NM.DeviceType.ETHERNET)
|
||||
]
|
||||
|
||||
def on_device_added(self, device):
|
||||
device_type = device.get_device_type()
|
||||
if device_type == NM.DeviceType.WIFI and not self._wifi_device:
|
||||
logger.info("[Network] WiFi device detected, initializing...")
|
||||
self._wifi_device = Wifi(client=self, device=device)
|
||||
self.wifi_device_added.emit()
|
||||
|
||||
elif device_type == NM.DeviceType.ETHERNET and not self._ethernet_device:
|
||||
logger.info("[Network] Ethernet device detected, initializing...")
|
||||
self._ethernet_device = Ethernet(client=self, device=device)
|
||||
self.ethernet_device_added.emit()
|
||||
|
||||
def on_device_removed(self, device):
|
||||
if device == self._wifi_device:
|
||||
logger.info("[Network] WiFi device removed.")
|
||||
self._wifi_device = None
|
||||
self.wifi_device_removed.emit()
|
||||
|
||||
elif device == self._ethernet_device:
|
||||
logger.info("[Network] Ethernet device removed.")
|
||||
self._ethernet_device = None
|
||||
self.ethernet_device_removed.emit()
|
||||
|
||||
def toggle_network(self):
|
||||
"""Enable or disable Network"""
|
||||
self.networking_enabled = not self.networking_enabled
|
||||
|
||||
def deactivate_connection(self, connection):
|
||||
"""Disconnect"""
|
||||
self._client.deactivate_connection_async(connection, None, None)
|
||||
|
||||
def notifier(self, name):
|
||||
self.notify(name)
|
||||
self.emit("changed")
|
||||
|
||||
|
||||
class AccessPoint(Service):
|
||||
"""A service to manage access points"""
|
||||
|
||||
@Signal
|
||||
def changed(self) -> None: ...
|
||||
|
||||
@Property(object, "readable")
|
||||
def device(self) -> object:
|
||||
return self._device
|
||||
|
||||
@Property(int, "readable")
|
||||
def strength(self) -> int:
|
||||
return self._ap.get_property("strength")
|
||||
|
||||
@Property(int, "readable")
|
||||
def frequency(self) -> int:
|
||||
return self._ap.get_property("frequency")
|
||||
|
||||
@Property(str, "readable")
|
||||
def bssid(self) -> str:
|
||||
return self._ap.get_property("bssid") if self._ap else None
|
||||
|
||||
@Property(str, "readable")
|
||||
def hw_address(self) -> str:
|
||||
return self._ap.get_property("hw_address")
|
||||
|
||||
@Property(str, "readable")
|
||||
def ssid(self) -> str:
|
||||
ssid = self._ap.get_ssid()
|
||||
return NM.utils_ssid_to_utf8(ssid.get_data()) if ssid else "Unknown"
|
||||
|
||||
@Property(str, "readable")
|
||||
def icon(self) -> str:
|
||||
return {
|
||||
80: "network-wireless-signal-excellent-symbolic",
|
||||
60: "network-wireless-signal-good-symbolic",
|
||||
40: "network-wireless-signal-ok-symbolic",
|
||||
20: "network-wireless-signal-weak-symbolic",
|
||||
00: "network-wireless-signal-none-symbolic",
|
||||
}.get(
|
||||
min(80, 20 * round(self.strength / 20)),
|
||||
"network-wireless-no-route-symbolic",
|
||||
)
|
||||
|
||||
@Property(bool, "readable", default_value=False)
|
||||
def requires_password(self) -> bool:
|
||||
ssid = self.ssid
|
||||
settings = self._client.connections
|
||||
connection = None
|
||||
for setting in settings:
|
||||
wifi_setting = setting.get_setting_wireless()
|
||||
if (
|
||||
wifi_setting
|
||||
and NM.utils_ssid_to_utf8(wifi_setting.get_ssid().get_data()) == ssid
|
||||
):
|
||||
connection = setting
|
||||
break
|
||||
if not connection:
|
||||
return bool(self._ap.get_wpa_flags() or self._ap.get_rsn_flags())
|
||||
return False
|
||||
|
||||
@Property(bool, "readable", default_value=False)
|
||||
def is_active(self) -> bool:
|
||||
if self._device.active_access_point:
|
||||
return self.bssid == self._device.active_access_point.get_bssid()
|
||||
return False
|
||||
|
||||
def __init__(self, device: "Wifi", ap: NM.AccessPoint, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._client: NetworkClient = device.client
|
||||
self._device: Wifi = device
|
||||
self._ap: NM.AccessPoint = ap
|
||||
|
||||
self._ap.connect("notify::strength", lambda *args: self.notifier("strength"))
|
||||
self._device.connect(
|
||||
"notify::active-access-point", lambda *args: self.notifier("is-active")
|
||||
)
|
||||
|
||||
def notifier(self, name: str, *args):
|
||||
self.notify(name)
|
||||
self.emit("changed")
|
||||
return
|
||||
|
||||
|
||||
class Wifi(Service):
|
||||
"""A service to manage wifi devices"""
|
||||
|
||||
@Signal
|
||||
def changed(self) -> None: ...
|
||||
|
||||
@Signal
|
||||
def ap_added(self, ap: AccessPoint) -> None: ...
|
||||
|
||||
@Signal
|
||||
def ap_removed(self, ap: AccessPoint) -> None: ...
|
||||
|
||||
@Property(NetworkClient, "readable")
|
||||
def client(self) -> NetworkClient:
|
||||
"""Returns the client"""
|
||||
return self._client
|
||||
|
||||
@Property(bool, "read-write", default_value=False)
|
||||
def wireless_enabled(self) -> bool:
|
||||
"""Returns if the wifi is enabled"""
|
||||
return self._client.get_property("wireless_enabled")
|
||||
|
||||
@wireless_enabled.setter
|
||||
def wireless_enabled(self, value: bool):
|
||||
return self._client.set_property("wireless_enabled", value)
|
||||
|
||||
@Property(list[AccessPoint], "readable")
|
||||
def access_points(self) -> list[AccessPoint]:
|
||||
return sorted(
|
||||
self._access_points.values(), key=lambda x: x.is_active, reverse=True
|
||||
)
|
||||
|
||||
@Property(AccessPoint, "readable")
|
||||
def active_access_point(self) -> Optional[AccessPoint]:
|
||||
return self._active_access_point
|
||||
|
||||
def __init__(self, client: NetworkClient, device: NM.DeviceWifi, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._client: NetworkClient = client
|
||||
self._device: NM.DeviceWifi = device
|
||||
self._active_access_point: NM.AccessPoint | None = None
|
||||
self._access_points: dict[str, AccessPoint] = {}
|
||||
|
||||
bulk_connect(
|
||||
self._device,
|
||||
{
|
||||
"notify::active-access-point": lambda *args: self.on_access_point_activated(),
|
||||
"access-point-added": lambda _, ap: self.on_access_point_added(ap=ap),
|
||||
"access-point-removed": lambda _, ap: self.on_access_point_removed(
|
||||
ap=ap
|
||||
),
|
||||
# "state-changed": lambda device, new, old, reason: self.on_state_changed(new),
|
||||
},
|
||||
)
|
||||
|
||||
self._client.connect(
|
||||
"notify::wireless-enabled", lambda *args: self.notifier("wireless-enabled")
|
||||
)
|
||||
|
||||
for ap in self.do_get_access_points():
|
||||
self.on_access_point_added(ap=ap)
|
||||
|
||||
self.on_access_point_activated()
|
||||
|
||||
def on_state_changed(self, state):
|
||||
self.emit("changed")
|
||||
|
||||
def do_get_access_points(self):
|
||||
return self._device.get_access_points()
|
||||
|
||||
def on_access_point_added(self, ap):
|
||||
ssid = ap.get_ssid()
|
||||
ssid = NM.utils_ssid_to_utf8(ssid.get_data()) if ssid else "Unknown"
|
||||
|
||||
access_point: AccessPoint = AccessPoint(ap=ap, device=self)
|
||||
|
||||
self._access_points[ssid] = access_point
|
||||
|
||||
self.ap_added.emit(access_point)
|
||||
|
||||
# self.notifier("access-points")
|
||||
|
||||
logger.info(f"[Wifi] New access point added with ssid: {ssid}")
|
||||
|
||||
def on_access_point_removed(self, ap):
|
||||
ssid = ap.get_ssid()
|
||||
ssid = NM.utils_ssid_to_utf8(ssid.get_data()) if ssid else "Unknown"
|
||||
|
||||
if not (access_point := self._access_points.pop(ssid, None)):
|
||||
return logger.warning(
|
||||
f"[Network] tried to remove a unknwon access point with ssid {ssid}"
|
||||
)
|
||||
|
||||
self.ap_removed.emit(access_point)
|
||||
|
||||
logger.info(f"[Wifi] Access point with ssid: {ssid} removed.")
|
||||
|
||||
def on_access_point_activated(self):
|
||||
if self._device.get_active_access_point():
|
||||
self._active_access_point: AccessPoint = AccessPoint(
|
||||
ap=self._device.get_active_access_point(), device=self
|
||||
)
|
||||
|
||||
else:
|
||||
self._active_access_point = None
|
||||
|
||||
self.notifier("active-access-point")
|
||||
|
||||
logger.info("[Wifi] New active connection")
|
||||
|
||||
def disconnect_wifi(self):
|
||||
"""Disconnect from the current WiFi network."""
|
||||
active_connection = self._device.get_active_connection()
|
||||
self._client.deactivate_connection(active_connection)
|
||||
logger.info("[Wifi] Wifi network disconnected")
|
||||
|
||||
def scan(self):
|
||||
self._device.request_scan_async(
|
||||
None,
|
||||
lambda device, result: [
|
||||
device.request_scan_finish(result),
|
||||
self.notifier("access-points"),
|
||||
],
|
||||
)
|
||||
logger.info("[Wifi] Scan started")
|
||||
|
||||
def toggle_wifi(self):
|
||||
"""Enable or disable WiFi"""
|
||||
self.wireless_enabled = not self.wireless_enabled
|
||||
|
||||
def connect_to_wifi(self, ap: AccessPoint, password: str = None):
|
||||
"""Connect to a WiFi network."""
|
||||
|
||||
ssid = ap.ssid
|
||||
|
||||
if ssid == "Unknown":
|
||||
logger.error("Invalid access point data")
|
||||
return False
|
||||
|
||||
logger.info(f"Connecting to WiFi SSID: {ssid}")
|
||||
|
||||
# Check for existing connections
|
||||
settings = self._client.connections
|
||||
connection = None
|
||||
|
||||
for setting in settings:
|
||||
wifi_setting = setting.get_setting_wireless()
|
||||
if (
|
||||
wifi_setting
|
||||
and NM.utils_ssid_to_utf8(wifi_setting.get_ssid().get_data()) == ssid
|
||||
):
|
||||
connection = setting
|
||||
break
|
||||
|
||||
if not connection:
|
||||
# Create a new connection profile
|
||||
logger.info(f"Creating new WiFi connection for SSID '{ssid}'")
|
||||
connection = NM.SimpleConnection.new()
|
||||
|
||||
# Required connection settings
|
||||
s_con = NM.SettingConnection.new()
|
||||
s_con.set_property(NM.SETTING_CONNECTION_ID, ssid)
|
||||
s_con.set_property(NM.SETTING_CONNECTION_TYPE, "802-11-wireless")
|
||||
s_con.set_property(
|
||||
NM.SETTING_CONNECTION_INTERFACE_NAME, self._device.get_iface()
|
||||
) # Set interface name
|
||||
connection.add_setting(s_con)
|
||||
|
||||
# Wireless settings
|
||||
s_wifi = NM.SettingWireless.new()
|
||||
s_wifi.set_property(NM.SETTING_WIRELESS_SSID, GLib.Bytes.new(ssid.encode()))
|
||||
s_wifi.set_property(
|
||||
NM.SETTING_WIRELESS_MODE, "infrastructure"
|
||||
) # Ensure mode is correct
|
||||
connection.add_setting(s_wifi)
|
||||
|
||||
# Security settings (only if password is required and provided)
|
||||
if ap.requires_password:
|
||||
if not password:
|
||||
logger.error("Password required but not provided")
|
||||
return False
|
||||
|
||||
s_sec = NM.SettingWirelessSecurity.new()
|
||||
s_sec.set_property(NM.SETTING_WIRELESS_SECURITY_KEY_MGMT, "wpa-psk")
|
||||
s_sec.set_property(NM.SETTING_WIRELESS_SECURITY_PSK, password)
|
||||
connection.add_setting(s_sec)
|
||||
|
||||
# IPv4 settings
|
||||
s_ipv4 = NM.SettingIP4Config.new()
|
||||
s_ipv4.set_property("method", "auto")
|
||||
connection.add_setting(s_ipv4)
|
||||
|
||||
# IPv6 settings
|
||||
s_ipv6 = NM.SettingIP6Config.new()
|
||||
s_ipv6.set_property("method", "auto")
|
||||
connection.add_setting(s_ipv6)
|
||||
|
||||
# Callback for async connection
|
||||
def on_connection_added(client, result, ssid, device):
|
||||
try:
|
||||
new_connection = client.add_connection_finish(result)
|
||||
if not new_connection:
|
||||
logger.error(f"Failed to create connection for '{ssid}'")
|
||||
return
|
||||
|
||||
logger.info(f"Connection for '{ssid}' added successfully")
|
||||
|
||||
client.activate_connection_async(
|
||||
new_connection,
|
||||
device,
|
||||
None,
|
||||
None,
|
||||
lambda c, r: logger.info(f"Connected to '{ssid}'")
|
||||
if c
|
||||
else logger.error(f"Failed to connect to '{ssid}'"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add connection: {e}")
|
||||
|
||||
# Save the new connection
|
||||
self._client._client.add_connection_async(
|
||||
connection,
|
||||
True,
|
||||
None,
|
||||
lambda c, r: on_connection_added(c, r, ssid, self._device),
|
||||
)
|
||||
|
||||
# Activate the connection
|
||||
self._client._client.activate_connection_async(
|
||||
connection,
|
||||
self._device,
|
||||
None,
|
||||
None,
|
||||
lambda client, result: logger.info(f"Connected to '{ssid}'"),
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
def notifier(self, name: str, *args):
|
||||
self.notify(name)
|
||||
self.emit("changed")
|
||||
return
|
||||
|
||||
|
||||
class Ethernet(Service):
|
||||
"""A service to manage ethernet devices"""
|
||||
|
||||
@Signal
|
||||
def changed(self) -> None: ...
|
||||
|
||||
@Signal
|
||||
def enabled(self) -> bool: ...
|
||||
|
||||
@Property(int, "readable")
|
||||
def speed(self) -> str:
|
||||
speed_mbps = self._device.get_speed()
|
||||
return f"{speed_mbps} Mb/s"
|
||||
|
||||
@Property(str, "readable")
|
||||
def state(self) -> str:
|
||||
return snake_case_to_kebab_case(
|
||||
get_enum_member_name(self._device.get_state(), default="disconnected")
|
||||
)
|
||||
|
||||
@Property(str, "readable")
|
||||
def internet(self) -> str:
|
||||
if self._active_connection:
|
||||
return snake_case_to_kebab_case(
|
||||
get_enum_member_name(
|
||||
self._active_connection.get_state(), default="disconnected"
|
||||
)
|
||||
)
|
||||
return "disconnected"
|
||||
|
||||
@Property(str, "readable")
|
||||
def iface(self) -> str:
|
||||
return self._device.get_iface() if self._device else None
|
||||
|
||||
@Property(str, "readable")
|
||||
def icon_name(self) -> str:
|
||||
network = self.internet
|
||||
if network == "activated":
|
||||
return "network-wired-symbolic"
|
||||
elif network == "activating":
|
||||
return "network-wired-acquiring-symbolic"
|
||||
return "network-wired-disconnected-symbolic"
|
||||
|
||||
def __init__(self, client: NM.Client, device: NM.DeviceEthernet, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._client: NM.Client = client
|
||||
self._device: NM.DeviceEthernet = device
|
||||
self._active_connection = None
|
||||
|
||||
self._device.connect(
|
||||
"state-changed", lambda *args: self.on_network_state_changed()
|
||||
)
|
||||
|
||||
self.update_active_connection()
|
||||
|
||||
def on_network_state_changed(self):
|
||||
"""Called when networking is toggled on/off."""
|
||||
if self.state != "unmanaged":
|
||||
# Re-initialize device when networking is re-enabled
|
||||
self.update_active_connection()
|
||||
else:
|
||||
self._active_connection = None
|
||||
|
||||
def update_active_connection(self):
|
||||
"""Updates the active connection and connects to its state change signal."""
|
||||
active_connection = self._device.get_active_connection()
|
||||
if active_connection:
|
||||
self._active_connection = active_connection
|
||||
self._active_connection.connect(
|
||||
"state-changed", lambda *args: self.emit("changed")
|
||||
)
|
||||
|
||||
def get_network_stats(self):
|
||||
"""Fetch received and transmitted bytes from the system files"""
|
||||
try:
|
||||
# Read data from /sys/class/net for accurate speed
|
||||
with open(
|
||||
f"/sys/class/net/{self.iface}/statistics/rx_bytes", "r"
|
||||
) as rx_file:
|
||||
rx_bytes = int(rx_file.read().strip())
|
||||
with open(
|
||||
f"/sys/class/net/{self.iface}/statistics/tx_bytes", "r"
|
||||
) as tx_file:
|
||||
tx_bytes = int(tx_file.read().strip())
|
||||
return rx_bytes, tx_bytes
|
||||
except FileNotFoundError:
|
||||
return None, None
|
||||
Reference in new issue
Block a user