211 lines
6.7 KiB
Python
211 lines
6.7 KiB
Python
import re
|
|
|
|
from fabric.core.service import Property, Service, Signal
|
|
from fabric.utils import Gio, GLib, logger
|
|
|
|
from services.config import get_config, on_config_change
|
|
|
|
_CONFIG_KEY = "night_light_temperature"
|
|
_DEFAULT_TEMPERATURE = 4500
|
|
_NEUTRAL_TEMPERATURE = 6000
|
|
_PROFILE_TEMP_RE = re.compile(r"(\d{3,5})")
|
|
|
|
|
|
def _coerce_temperature(value, fallback: int = _DEFAULT_TEMPERATURE) -> int:
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError) as e:
|
|
logger.warning(f"[nightlight] return int(value) failed: {e}")
|
|
return fallback
|
|
|
|
|
|
class NightLightService(Service):
|
|
"""Blue-light filter service backed by a persistent hyprsunset daemon.
|
|
|
|
All state changes go through ``hyprctl hyprsunset`` IPC so the daemon is
|
|
started at most once and never respawned on every toggle.
|
|
"""
|
|
|
|
_instance = None
|
|
|
|
def __new__(cls, *args, **kwargs):
|
|
if cls._instance is None:
|
|
cls._instance = super().__new__(cls)
|
|
return cls._instance
|
|
|
|
@Signal
|
|
def night_light_changed(self, active: bool) -> None:
|
|
"""Emitted when the filter is enabled or disabled."""
|
|
|
|
@Signal
|
|
def temperature_changed(self, value: int) -> None:
|
|
"""Emitted when the color temperature changes."""
|
|
|
|
def __init__(self, **kwargs):
|
|
if getattr(self, "_initialized", False):
|
|
return
|
|
self._initialized = True
|
|
super().__init__(**kwargs)
|
|
self._active = False
|
|
self._temperature = _coerce_temperature(get_config(_CONFIG_KEY))
|
|
self._process: Gio.Subprocess | None = None
|
|
self._daemon_running = False
|
|
on_config_change(self._on_config_change)
|
|
self._sync_state()
|
|
|
|
@Property(bool, "readable", default_value=False)
|
|
def active(self) -> bool:
|
|
return self._active
|
|
|
|
@Property(int, "readable", default_value=_DEFAULT_TEMPERATURE)
|
|
def temperature(self) -> int:
|
|
return self._temperature
|
|
|
|
def is_active(self) -> bool:
|
|
return self._active
|
|
|
|
def enable(self):
|
|
kelvin = self._temperature or _DEFAULT_TEMPERATURE
|
|
if self._daemon_running:
|
|
self._hyprctl("temperature", kelvin)
|
|
else:
|
|
self._start_daemon(["-t", str(kelvin)])
|
|
self._set_temperature(kelvin)
|
|
self._set_active(True)
|
|
|
|
def disable(self):
|
|
if self._daemon_running:
|
|
self._hyprctl("identity")
|
|
self._set_active(False)
|
|
|
|
def toggle(self) -> bool:
|
|
if self._active:
|
|
self.disable()
|
|
else:
|
|
self.enable()
|
|
return self._active
|
|
|
|
def set_temperature(self, kelvin: int):
|
|
self._set_temperature(kelvin)
|
|
if not self._active:
|
|
return
|
|
if self._daemon_running:
|
|
self._hyprctl("temperature", kelvin)
|
|
else:
|
|
self._start_daemon(["-t", str(kelvin)])
|
|
|
|
def set_gamma(self, percent: int):
|
|
if self._daemon_running:
|
|
self._hyprctl("gamma", percent)
|
|
else:
|
|
self._start_daemon(["-g", str(percent)])
|
|
|
|
def reset(self):
|
|
if self._daemon_running:
|
|
self._hyprctl("reset")
|
|
self._sync_state()
|
|
|
|
def _on_config_change(self, new_config, old_config):
|
|
if new_config.get(_CONFIG_KEY) == old_config.get(_CONFIG_KEY):
|
|
return
|
|
self.set_temperature(_coerce_temperature(new_config.get(_CONFIG_KEY)))
|
|
|
|
def _set_active(self, value: bool):
|
|
if value != self._active:
|
|
self._active = value
|
|
self.emit("night_light_changed", value)
|
|
|
|
def _set_temperature(self, value: int):
|
|
if value != self._temperature:
|
|
self._temperature = value
|
|
self.emit("temperature_changed", value)
|
|
|
|
def _start_daemon(self, args: list[str]):
|
|
try:
|
|
self._process = Gio.Subprocess.new(
|
|
["hyprsunset", *args],
|
|
Gio.SubprocessFlags.STDOUT_SILENCE | Gio.SubprocessFlags.STDERR_SILENCE,
|
|
)
|
|
except GLib.Error as error:
|
|
logger.error(f"[NightLightService] Failed to start hyprsunset: {error}")
|
|
self._process = None
|
|
return
|
|
self._daemon_running = True
|
|
self._process.wait_async(None, self._on_daemon_exit)
|
|
|
|
def _on_daemon_exit(self, process: Gio.Subprocess, task: Gio.Task):
|
|
# Ignore exit from a stale subprocess — a new daemon may have been
|
|
# started since this one was spawned.
|
|
if self._process is not process:
|
|
return
|
|
self._process = None
|
|
self._daemon_running = False
|
|
self._set_active(False)
|
|
|
|
def _hyprctl(self, *args, on_reply=None):
|
|
argv = ["hyprctl", "hyprsunset", *[str(arg) for arg in args]]
|
|
try:
|
|
proc = Gio.Subprocess.new(
|
|
argv,
|
|
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_SILENCE,
|
|
)
|
|
except GLib.Error as error:
|
|
logger.error(f"[NightLightService] Failed to run {argv}: {error}")
|
|
if on_reply:
|
|
on_reply(None)
|
|
return
|
|
|
|
def on_done(process: Gio.Subprocess, task: Gio.Task):
|
|
try:
|
|
_, stdout, _ = process.communicate_utf8_finish(task)
|
|
except GLib.Error:
|
|
stdout = None
|
|
if on_reply:
|
|
on_reply(stdout.strip() if stdout else None)
|
|
|
|
proc.communicate_utf8_async(None, None, on_done)
|
|
|
|
def _sync_state(self):
|
|
self._hyprctl("profile", on_reply=self._on_profile_reply)
|
|
|
|
def _on_profile_reply(self, reply: str | None):
|
|
if not self._daemon_alive(reply):
|
|
self._daemon_running = False
|
|
return
|
|
self._daemon_running = True
|
|
temperature = self._parse_temperature(reply)
|
|
if temperature is not None:
|
|
self._apply_synced_temperature(temperature)
|
|
else:
|
|
self._hyprctl("temperature", on_reply=self._on_temperature_reply)
|
|
|
|
def _on_temperature_reply(self, reply: str | None):
|
|
if reply is None:
|
|
return
|
|
temperature = self._parse_temperature(reply)
|
|
if temperature is not None:
|
|
self._apply_synced_temperature(temperature)
|
|
|
|
def _apply_synced_temperature(self, temperature: int):
|
|
active = temperature < _NEUTRAL_TEMPERATURE
|
|
if active:
|
|
self._set_temperature(temperature)
|
|
self._set_active(active)
|
|
|
|
@staticmethod
|
|
def _daemon_alive(reply: str | None) -> bool:
|
|
if reply is None:
|
|
return False
|
|
return "couldn't connect" not in reply.lower()
|
|
|
|
@staticmethod
|
|
def _parse_temperature(reply: str | None) -> int | None:
|
|
if not reply:
|
|
return None
|
|
match = _PROFILE_TEMP_RE.search(reply)
|
|
return int(match.group(1)) if match else None
|
|
|
|
|
|
def get_night_light_service() -> NightLightService:
|
|
return NightLightService()
|