feat(appearance): integrate Night Light controls
This commit is contained in:
parent
3a37c90080
commit
f96f75cc2b
14 changed files with 691 additions and 6 deletions
324
bin/.local/bin/night-light-manager.py
Normal file
324
bin/.local/bin/night-light-manager.py
Normal file
|
|
@ -0,0 +1,324 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Préférences Night Light locales et cycle de vie de l'unique wlsunset.service."""
|
||||||
|
import argparse
|
||||||
|
import fcntl
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
|
||||||
|
CONFIG = Path(os.environ.get('XDG_CONFIG_HOME', Path.home() / '.config')) / 'labfy-appearance/preferences.json'
|
||||||
|
STATE = Path(os.environ.get('XDG_STATE_HOME', Path.home() / '.local/state')) / 'labfy-appearance/effective.json'
|
||||||
|
LOCK = STATE.parent / 'wallpaper.lock'
|
||||||
|
UNIT = 'wlsunset.service'
|
||||||
|
WLSUNSET = '/usr/bin/wlsunset'
|
||||||
|
MODES = ('off', 'auto', 'on')
|
||||||
|
MIN_NIGHT, MAX_NIGHT, STEP = 2500, 5000, 100
|
||||||
|
DAY_TEMPERATURE = 6500
|
||||||
|
|
||||||
|
|
||||||
|
def read_json(path):
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text(encoding='utf-8'))
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def atomic_json(path, value):
|
||||||
|
# CONTRACT: préférences et état sont chacun publiés par replace durable ;
|
||||||
|
# les permissions 0600 protègent les coordonnées locales hors Git.
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd, temporary = tempfile.mkstemp(prefix='.night-light-', dir=path.parent)
|
||||||
|
try:
|
||||||
|
os.fchmod(fd, 0o600)
|
||||||
|
with os.fdopen(fd, 'w', encoding='utf-8') as stream:
|
||||||
|
json.dump(value, stream, ensure_ascii=False, separators=(',', ':'))
|
||||||
|
stream.write('\n')
|
||||||
|
stream.flush()
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
os.replace(temporary, path)
|
||||||
|
directory = os.open(path.parent, os.O_RDONLY)
|
||||||
|
try:
|
||||||
|
os.fsync(directory)
|
||||||
|
finally:
|
||||||
|
os.close(directory)
|
||||||
|
finally:
|
||||||
|
Path(temporary).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def restore(path, old):
|
||||||
|
if old is None:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
else:
|
||||||
|
fd, temporary = tempfile.mkstemp(prefix='.night-light-rollback-', dir=path.parent)
|
||||||
|
try:
|
||||||
|
os.fchmod(fd, 0o600)
|
||||||
|
with os.fdopen(fd, 'wb') as stream:
|
||||||
|
stream.write(old)
|
||||||
|
stream.flush()
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
os.replace(temporary, path)
|
||||||
|
finally:
|
||||||
|
Path(temporary).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def temperature(value):
|
||||||
|
# INVARIANT: aucun arrondi implicite ni NaN ne peut devenir un argument CLI.
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int) or not MIN_NIGHT <= value <= MAX_NIGHT or value % STEP:
|
||||||
|
raise ValueError('Température de nuit invalide (2500–5000 K, pas de 100 K)')
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def coordinates(pref):
|
||||||
|
try:
|
||||||
|
lat, lon = float(pref['latitude']), float(pref['longitude'])
|
||||||
|
if not math.isfinite(lat) or not math.isfinite(lon) or not -90 <= lat <= 90 or not -180 <= lon <= 180:
|
||||||
|
raise ValueError()
|
||||||
|
return str(pref['latitude']), str(pref['longitude'])
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
raise ValueError('Planification à configurer') from None
|
||||||
|
|
||||||
|
|
||||||
|
def normalized(pref):
|
||||||
|
result = dict(pref)
|
||||||
|
mode = result.get('nightLightMode', 'auto' if 'latitude' in result and 'longitude' in result else 'off')
|
||||||
|
if mode not in MODES:
|
||||||
|
raise ValueError('Mode Night Light invalide')
|
||||||
|
result.update(version=max(3, int(result.get('version', 2))),
|
||||||
|
nightLightMode=mode,
|
||||||
|
nightTemperature=temperature(result.get('nightTemperature', 3000)),
|
||||||
|
dayTemperature=result.get('dayTemperature', DAY_TEMPERATURE),
|
||||||
|
scheduleType=result.get('scheduleType', 'solar'))
|
||||||
|
if result['dayTemperature'] != DAY_TEMPERATURE or result['scheduleType'] != 'solar':
|
||||||
|
raise ValueError('Configuration Night Light non prise en charge')
|
||||||
|
if mode == 'auto':
|
||||||
|
coordinates(result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def effective(mode, suspended=False):
|
||||||
|
# CONTRACT 17F: une suspension Soleil n'écrase jamais le choix utilisateur.
|
||||||
|
return 'off' if suspended else mode
|
||||||
|
|
||||||
|
|
||||||
|
def argv(pref):
|
||||||
|
pref = normalized(pref)
|
||||||
|
mode = effective(pref['nightLightMode'])
|
||||||
|
if mode == 'off':
|
||||||
|
return None
|
||||||
|
temperatures = ['-t', str(pref['nightTemperature']), '-T', str(pref['dayTemperature'])]
|
||||||
|
if mode == 'auto':
|
||||||
|
lat, lon = coordinates(pref)
|
||||||
|
return [WLSUNSET, '-l', lat, '-L', lon, *temperatures]
|
||||||
|
# WHY: wlsunset 0.4.0 refuse -T == -t. Un horaire manuel valide démarre
|
||||||
|
# le processus ; ExecStartPost sélectionne ensuite FORCE_LOW via SIGUSR1.
|
||||||
|
return [WLSUNSET, '-S', '06:00', '-s', '18:00', *temperatures]
|
||||||
|
|
||||||
|
|
||||||
|
def systemctl(*args):
|
||||||
|
result = subprocess.run(['systemctl', '--user', *args], text=True, capture_output=True)
|
||||||
|
if result.returncode:
|
||||||
|
raise RuntimeError('Échec de gestion de wlsunset.service')
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def unit_state():
|
||||||
|
lines = systemctl('show', UNIT, '-p', 'ActiveState', '-p', 'MainPID').splitlines()
|
||||||
|
fields = dict(line.split('=', 1) for line in lines if '=' in line)
|
||||||
|
return fields.get('ActiveState') == 'active', int(fields.get('MainPID', '0'))
|
||||||
|
|
||||||
|
|
||||||
|
def wlsunset_pids():
|
||||||
|
# INVARIANT: inspection seule ; aucun pkill/killall global n'est permis.
|
||||||
|
found = []
|
||||||
|
for entry in Path('/proc').iterdir():
|
||||||
|
if not entry.name.isdigit():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
# Le nom du processus couvre aussi un lancement externe par PATH.
|
||||||
|
if (entry / 'comm').read_text(encoding='ascii').strip() == 'wlsunset':
|
||||||
|
found.append(int(entry.name))
|
||||||
|
except (FileNotFoundError, PermissionError, ProcessLookupError):
|
||||||
|
pass
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def verify(pref):
|
||||||
|
active, main_pid = unit_state()
|
||||||
|
pids = wlsunset_pids()
|
||||||
|
mode = effective(pref['nightLightMode'])
|
||||||
|
if mode == 'off':
|
||||||
|
if active or pids:
|
||||||
|
raise RuntimeError('Night Light Off : wlsunset encore actif')
|
||||||
|
elif not active or pids != [main_pid]:
|
||||||
|
raise RuntimeError('wlsunset absent, dupliqué ou hors unité systemd')
|
||||||
|
else:
|
||||||
|
actual = (Path('/proc') / str(main_pid) / 'cmdline').read_bytes().split(b'\0')[:-1]
|
||||||
|
if actual != [part.encode() for part in argv(pref)]:
|
||||||
|
raise RuntimeError('Arguments wlsunset incohérents')
|
||||||
|
|
||||||
|
|
||||||
|
def apply_service(pref):
|
||||||
|
mode = effective(pref['nightLightMode'])
|
||||||
|
systemctl('stop' if mode == 'off' else 'restart', UNIT)
|
||||||
|
verify(pref)
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot(pref, state):
|
||||||
|
return {'nightLightMode': pref['nightLightMode'],
|
||||||
|
'effectiveNightLightMode': effective(pref['nightLightMode'], state.get('nightLightSuspended', False)),
|
||||||
|
'nightLightSuspended': bool(state.get('nightLightSuspended', False)),
|
||||||
|
'nightTemperature': pref['nightTemperature'], 'dayTemperature': pref['dayTemperature'],
|
||||||
|
'scheduleType': pref['scheduleType'], 'scheduleConfigured': 'latitude' in pref and 'longitude' in pref}
|
||||||
|
|
||||||
|
|
||||||
|
def publish(value):
|
||||||
|
payload = json.dumps(value, separators=(',', ':'))
|
||||||
|
try:
|
||||||
|
result = subprocess.run(['qs', '-c', 'labfy-sway', 'ipc', 'call', 'appearance', 'publishNightLight', payload],
|
||||||
|
text=True, capture_output=True)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return
|
||||||
|
# QuickShell peut être absent ; systemd et les préférences restent autonomes.
|
||||||
|
if result.returncode == 0 and result.stdout.strip() != 'true':
|
||||||
|
raise RuntimeError('QuickShell a refusé l’état Night Light')
|
||||||
|
|
||||||
|
|
||||||
|
def change(mode=None, night=None):
|
||||||
|
LOCK.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(LOCK, 'a+b') as lock:
|
||||||
|
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
old_pref_bytes = CONFIG.read_bytes() if CONFIG.exists() else None
|
||||||
|
old_state_bytes = STATE.read_bytes() if STATE.exists() else None
|
||||||
|
old_pref = normalized(read_json(CONFIG))
|
||||||
|
pref = dict(old_pref)
|
||||||
|
if mode is not None:
|
||||||
|
if mode not in MODES:
|
||||||
|
raise ValueError('Mode Night Light invalide')
|
||||||
|
pref['nightLightMode'] = mode
|
||||||
|
if night is not None:
|
||||||
|
pref['nightTemperature'] = temperature(night)
|
||||||
|
pref = normalized(pref)
|
||||||
|
state = read_json(STATE)
|
||||||
|
previous_mode = old_pref['nightLightMode']
|
||||||
|
if pref == old_pref:
|
||||||
|
verify(pref)
|
||||||
|
return snapshot(pref, state)
|
||||||
|
new_state = dict(state)
|
||||||
|
new_state.update(effectiveNightLightMode=effective(pref['nightLightMode']),
|
||||||
|
nightLightSuspended=False)
|
||||||
|
try:
|
||||||
|
atomic_json(CONFIG, pref)
|
||||||
|
apply_service(pref)
|
||||||
|
atomic_json(STATE, new_state)
|
||||||
|
result = snapshot(pref, new_state)
|
||||||
|
publish(result)
|
||||||
|
return result
|
||||||
|
except Exception:
|
||||||
|
restore(CONFIG, old_pref_bytes)
|
||||||
|
restore(STATE, old_state_bytes)
|
||||||
|
try:
|
||||||
|
apply_service(old_pref)
|
||||||
|
except Exception as rollback_error:
|
||||||
|
raise RuntimeError('Échec Night Light et rollback du service') from rollback_error
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile():
|
||||||
|
LOCK.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(LOCK, 'a+b') as lock:
|
||||||
|
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
pref = normalized(read_json(CONFIG))
|
||||||
|
state = read_json(STATE)
|
||||||
|
try:
|
||||||
|
verify(pref)
|
||||||
|
except RuntimeError:
|
||||||
|
# Une instance étrangère ne peut être supprimée par cette unité.
|
||||||
|
active, main_pid = unit_state()
|
||||||
|
if any(pid != main_pid for pid in wlsunset_pids()):
|
||||||
|
raise RuntimeError('wlsunset externe détecté ; réconciliation refusée')
|
||||||
|
apply_service(pref)
|
||||||
|
state.update(effectiveNightLightMode=effective(pref['nightLightMode']),
|
||||||
|
nightLightSuspended=False)
|
||||||
|
if read_json(CONFIG) != pref:
|
||||||
|
atomic_json(CONFIG, pref)
|
||||||
|
if read_json(STATE) != state:
|
||||||
|
atomic_json(STATE, state)
|
||||||
|
return snapshot(pref, state)
|
||||||
|
|
||||||
|
|
||||||
|
def launch():
|
||||||
|
pref = normalized(read_json(CONFIG))
|
||||||
|
command = argv(pref)
|
||||||
|
if command is None:
|
||||||
|
return 0
|
||||||
|
os.execv(command[0], command)
|
||||||
|
|
||||||
|
|
||||||
|
def post_start():
|
||||||
|
pref = normalized(read_json(CONFIG))
|
||||||
|
if pref['nightLightMode'] != 'on':
|
||||||
|
return 0
|
||||||
|
# systemd ne fournit pas MAINPID dans cet ExecStartPost sur cette version.
|
||||||
|
# Interroger le PID de l'unité et attendre la fin de l'exec Python -> wlsunset.
|
||||||
|
deadline = time.monotonic() + 3
|
||||||
|
pid = 0
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
pid = int(systemctl('show', '-P', 'MainPID', UNIT) or '0')
|
||||||
|
try:
|
||||||
|
if pid > 0 and (Path('/proc') / str(pid) / 'cmdline').read_bytes().startswith(WLSUNSET.encode() + b'\0'):
|
||||||
|
break
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
time.sleep(0.1)
|
||||||
|
else:
|
||||||
|
raise RuntimeError('PID wlsunset principal invalide')
|
||||||
|
# CONTRACT: SIGUSR1 parcourt AUTO -> FORCE_HIGH -> FORCE_LOW en 0.4.0.
|
||||||
|
# Laisser l'event loop traiter chaque signal pour ne jamais perdre un cran.
|
||||||
|
time.sleep(0.3)
|
||||||
|
os.kill(pid, signal.SIGUSR1)
|
||||||
|
time.sleep(0.3)
|
||||||
|
os.kill(pid, signal.SIGUSR1)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
sub = parser.add_subparsers(dest='action', required=True)
|
||||||
|
for action in ('current', 'reconcile', 'validate', 'launch', 'post-start'):
|
||||||
|
sub.add_parser(action)
|
||||||
|
sub.add_parser('set-mode').add_argument('mode')
|
||||||
|
sub.add_parser('set-night-temperature').add_argument('value')
|
||||||
|
args = parser.parse_args()
|
||||||
|
try:
|
||||||
|
if args.action == 'launch':
|
||||||
|
return launch()
|
||||||
|
if args.action == 'post-start':
|
||||||
|
return post_start()
|
||||||
|
if args.action == 'reconcile':
|
||||||
|
result = reconcile()
|
||||||
|
elif args.action == 'set-mode':
|
||||||
|
result = change(mode=args.mode)
|
||||||
|
elif args.action == 'set-night-temperature':
|
||||||
|
if not args.value.isdecimal():
|
||||||
|
raise ValueError('Température de nuit invalide')
|
||||||
|
result = change(night=int(args.value))
|
||||||
|
else:
|
||||||
|
pref = normalized(read_json(CONFIG))
|
||||||
|
result = snapshot(pref, read_json(STATE))
|
||||||
|
if args.action == 'current':
|
||||||
|
verify(pref)
|
||||||
|
print(json.dumps(result, ensure_ascii=False, separators=(',', ':')))
|
||||||
|
return 0
|
||||||
|
except (OSError, ValueError, RuntimeError, BlockingIOError) as exc:
|
||||||
|
print('Erreur : ' + str(exc), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -37,7 +37,7 @@ GENERATED_THEME = SWAY / 'generated/theme.conf'
|
||||||
ALIASES = Path(os.environ.get('XDG_CONFIG_HOME', Path.home() / '.config')) / 'labfy-appearance/wallpapers'
|
ALIASES = Path(os.environ.get('XDG_CONFIG_HOME', Path.home() / '.config')) / 'labfy-appearance/wallpapers'
|
||||||
FLAVORS = ('latte', 'frappe', 'macchiato', 'mocha')
|
FLAVORS = ('latte', 'frappe', 'macchiato', 'mocha')
|
||||||
STATE_VERSION = 3
|
STATE_VERSION = 3
|
||||||
PREF_VERSION = 2
|
PREF_VERSION = 3
|
||||||
|
|
||||||
|
|
||||||
def atomic_write(path, data):
|
def atomic_write(path, data):
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ Item {
|
||||||
signal backRequested()
|
signal backRequested()
|
||||||
signal wallpaperRequested()
|
signal wallpaperRequested()
|
||||||
signal themeRequested()
|
signal themeRequested()
|
||||||
|
signal nightLightRequested()
|
||||||
readonly property var flavorNames: ({ latte: "Latte", frappe: "Frappé", macchiato: "Macchiato", mocha: "Mocha" })
|
readonly property var flavorNames: ({ latte: "Latte", frappe: "Frappé", macchiato: "Macchiato", mocha: "Mocha" })
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
|
|
@ -44,5 +45,21 @@ Item {
|
||||||
}
|
}
|
||||||
MouseArea { id: themePointer; anchors.fill: parent; hoverEnabled: true; onClicked: page.themeRequested() }
|
MouseArea { id: themePointer; anchors.fill: parent; hoverEnabled: true; onClicked: page.themeRequested() }
|
||||||
}
|
}
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width; height: 72; radius: 4
|
||||||
|
color: nightPointer.containsMouse ? Theme.buttonHover : Theme.buttonBackground
|
||||||
|
NerdIcon { anchors.left: parent.left; anchors.leftMargin: 12; anchors.verticalCenter: parent.verticalCenter; text: ""; color: Theme.accent; font.pixelSize: 22 }
|
||||||
|
Column {
|
||||||
|
anchors.left: parent.left; anchors.leftMargin: 47; anchors.verticalCenter: parent.verticalCenter; spacing: 4
|
||||||
|
Text { text: "Lumière nocturne"; color: Theme.foreground; font.pixelSize: 14; font.bold: true }
|
||||||
|
Text {
|
||||||
|
text: AppearanceController.nightLightMode === "auto" ? "Auto · " + AppearanceController.nightTemperature + " K"
|
||||||
|
: AppearanceController.nightLightMode === "on" ? "Activée · " + AppearanceController.nightTemperature + " K"
|
||||||
|
: "Désactivée"
|
||||||
|
color: Theme.secondaryForeground; font.pixelSize: 11
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MouseArea { id: nightPointer; anchors.fill: parent; hoverEnabled: true; onClicked: page.nightLightRequested() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ PopupWindow {
|
||||||
// CONTRACT: indices externes historiques conservés ; tout nouveau routage
|
// CONTRACT: indices externes historiques conservés ; tout nouveau routage
|
||||||
// passe par ces noms afin de ne pas multiplier les indices StackLayout.
|
// passe par ces noms afin de ne pas multiplier les indices StackLayout.
|
||||||
readonly property var pages: ({ main: 0, wifi: 1, bluetooth: 2, session: 3,
|
readonly property var pages: ({ main: 0, wifi: 1, bluetooth: 2, session: 3,
|
||||||
confirmation: 4, battery: 5, appearance: 6, wallpaper: 7, theme: 8 })
|
confirmation: 4, battery: 5, appearance: 6, wallpaper: 7, theme: 8, nightLight: 9 })
|
||||||
property int currentPage: pages.main
|
property int currentPage: pages.main
|
||||||
function openPage(name) { if (pages[name] !== undefined) currentPage = pages[name]; }
|
function openPage(name) { if (pages[name] !== undefined) currentPage = pages[name]; }
|
||||||
signal thresholdApplied()
|
signal thresholdApplied()
|
||||||
|
|
@ -208,6 +208,7 @@ PopupWindow {
|
||||||
onBackRequested: popup.openPage("main")
|
onBackRequested: popup.openPage("main")
|
||||||
onWallpaperRequested: popup.openPage("wallpaper")
|
onWallpaperRequested: popup.openPage("wallpaper")
|
||||||
onThemeRequested: popup.openPage("theme")
|
onThemeRequested: popup.openPage("theme")
|
||||||
|
onNightLightRequested: popup.openPage("nightLight")
|
||||||
}
|
}
|
||||||
|
|
||||||
WallpaperPage {
|
WallpaperPage {
|
||||||
|
|
@ -218,6 +219,9 @@ PopupWindow {
|
||||||
ThemePage {
|
ThemePage {
|
||||||
onBackRequested: popup.openPage("appearance")
|
onBackRequested: popup.openPage("appearance")
|
||||||
}
|
}
|
||||||
|
NightLightPage {
|
||||||
|
onBackRequested: popup.openPage("appearance")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import "../theme"
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: page
|
||||||
|
signal backRequested()
|
||||||
|
property int draftTemperature: AppearanceController.nightTemperature
|
||||||
|
readonly property bool busy: AppearanceController.nightLightBusy
|
||||||
|
Connections {
|
||||||
|
target: AppearanceController
|
||||||
|
function onNightTemperatureChanged() { page.draftTemperature = AppearanceController.nightTemperature; }
|
||||||
|
}
|
||||||
|
Column {
|
||||||
|
anchors.fill: parent
|
||||||
|
spacing: 14
|
||||||
|
Row {
|
||||||
|
spacing: 8; height: 30
|
||||||
|
ActionButton { label: ""; onClicked: page.backRequested() }
|
||||||
|
Text { text: "Lumière nocturne"; color: Theme.foreground; font.pixelSize: 16; font.bold: true; height: 30; verticalAlignment: Text.AlignVCenter }
|
||||||
|
}
|
||||||
|
Text { text: "MODE"; color: Theme.accentForeground; font.pixelSize: 11; font.bold: true }
|
||||||
|
Row {
|
||||||
|
spacing: 8
|
||||||
|
Repeater {
|
||||||
|
model: [
|
||||||
|
{ key: "off", label: "Désactivée" },
|
||||||
|
{ key: "auto", label: "Auto" },
|
||||||
|
{ key: "on", label: "Activée" }
|
||||||
|
]
|
||||||
|
delegate: Rectangle {
|
||||||
|
required property var modelData
|
||||||
|
readonly property bool selected: AppearanceController.nightLightMode === modelData.key
|
||||||
|
width: (page.width - 16) / 3; height: 44; radius: 4
|
||||||
|
color: selected ? Theme.selectedBackground : Theme.buttonBackground
|
||||||
|
border.width: selected ? 2 : 1
|
||||||
|
border.color: selected ? Theme.accent : Theme.border
|
||||||
|
opacity: page.busy ? 0.6 : 1
|
||||||
|
Text { anchors.centerIn: parent; text: modelData.label; color: parent.selected ? Theme.selectedForeground : Theme.foreground; font.pixelSize: 11; font.bold: parent.selected }
|
||||||
|
MouseArea { anchors.fill: parent; enabled: !page.busy && !parent.selected; onClicked: AppearanceController.setNightLightMode(modelData.key) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: AppearanceController.nightLightMode === "auto" ? "Lever et coucher du soleil"
|
||||||
|
: AppearanceController.nightLightMode === "on" ? AppearanceController.nightTemperature + " K en permanence"
|
||||||
|
: "Couleurs neutres"
|
||||||
|
color: Theme.secondaryForeground; font.pixelSize: 12
|
||||||
|
}
|
||||||
|
Text { text: "TEMPÉRATURE DE NUIT"; color: Theme.accentForeground; font.pixelSize: 11; font.bold: true }
|
||||||
|
Row {
|
||||||
|
width: parent.width; spacing: 10
|
||||||
|
Slider {
|
||||||
|
id: nightSlider
|
||||||
|
width: parent.width - 74
|
||||||
|
from: 2500; to: 5000; stepSize: 100
|
||||||
|
value: page.draftTemperature
|
||||||
|
enabled: !page.busy
|
||||||
|
onMoved: page.draftTemperature = Math.round(value / 100) * 100
|
||||||
|
}
|
||||||
|
Text { text: page.draftTemperature + " K"; color: Theme.foreground; font.pixelSize: 12; width: 64; verticalAlignment: Text.AlignVCenter; height: nightSlider.height }
|
||||||
|
}
|
||||||
|
ActionButton {
|
||||||
|
label: "Appliquer"
|
||||||
|
enabled: !page.busy && page.draftTemperature !== AppearanceController.nightTemperature
|
||||||
|
onClicked: AppearanceController.setNightTemperature(page.draftTemperature)
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
width: parent.width; wrapMode: Text.Wrap
|
||||||
|
text: AppearanceController.nightScheduleConfigured ? "Planification automatique · Position locale configurée" : "Planification à configurer"
|
||||||
|
color: Theme.secondaryForeground; font.pixelSize: 11
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
visible: page.busy || !!AppearanceController.nightLightError
|
||||||
|
width: parent.width; wrapMode: Text.Wrap
|
||||||
|
text: page.busy ? "Application…" : AppearanceController.nightLightError
|
||||||
|
color: AppearanceController.nightLightError ? Theme.warningForeground : Theme.secondaryForeground
|
||||||
|
font.pixelSize: 11
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -39,7 +39,7 @@ alias. Le chemin de l'utilisateur n'est jamais une commande shell.
|
||||||
L'application tient un verrou non bloquant, vérifie le décodage, écrit
|
L'application tient un verrou non bloquant, vérifie le décodage, écrit
|
||||||
`wallpaper.conf` par temp/flush/fsync/replace, lance `sway --validate`,
|
`wallpaper.conf` par temp/flush/fsync/replace, lance `sway --validate`,
|
||||||
`swaymsg reload`, puis vérifie le `swaybg` effectif avant de publier
|
`swaymsg reload`, puis vérifie le `swaybg` effectif avant de publier
|
||||||
`$XDG_STATE_HOME/labfy-appearance/effective.json` en schéma 2. En cas d'échec,
|
`$XDG_STATE_HOME/labfy-appearance/effective.json` en schéma 3. En cas d'échec,
|
||||||
elle restaure la configuration et l'état précédents puis recharge Sway.
|
elle restaure la configuration et l'état précédents puis recharge Sway.
|
||||||
Les champs 17B de thème restent intacts ; la révision augmente une fois par
|
Les champs 17B de thème restent intacts ; la révision augmente une fois par
|
||||||
application réussie et jamais par sélection. Les états 17B sans wallpaper
|
application réussie et jamais par sélection. Les états 17B sans wallpaper
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,9 @@ ShellRoot {
|
||||||
function publishState(payload: string): bool {
|
function publishState(payload: string): bool {
|
||||||
return AppearanceController.acceptPublished(payload);
|
return AppearanceController.acceptPublished(payload);
|
||||||
}
|
}
|
||||||
|
function publishNightLight(payload: string): bool {
|
||||||
|
return AppearanceController.acceptPublishedNightLight(payload);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Une barre par écran, y compris si les sorties changent pendant la session.
|
// Une barre par écran, y compris si les sorties changent pendant la session.
|
||||||
// CONTRACT: un seul serveur D-Bus pour toutes les sorties.
|
// CONTRACT: un seul serveur D-Bus pour toutes les sorties.
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ non cachées observées ont duré environ 0,03 à 0,10 s pour 1080p/4K et
|
||||||
En Auto, l'image est décodée et analysée avant toute écriture. Le backend
|
En Auto, l'image est décodée et analysée avant toute écriture. Le backend
|
||||||
prépare `generated/wallpaper.conf` et `generated/theme.conf` si nécessaires,
|
prépare `generated/wallpaper.conf` et `generated/theme.conf` si nécessaires,
|
||||||
valide Sway, recharge Sway, confirme `swaybg`, puis écrit `effective.json`
|
valide Sway, recharge Sway, confirme `swaybg`, puis écrit `effective.json`
|
||||||
schéma 3 et les préférences schéma 2 avant publication IPC QuickShell. Un
|
schéma 3 et les préférences schéma 3 (depuis 17E) avant publication IPC QuickShell. Un
|
||||||
verrou non bloquant commun empêche deux applications simultanées. Si une
|
verrou non bloquant commun empêche deux applications simultanées. Si une
|
||||||
étape échoue, les anciens fichiers générés et JSON sont restaurés et Sway
|
étape échoue, les anciens fichiers générés et JSON sont restaurés et Sway
|
||||||
est rechargé. Un échec d'analyse est signalé distinctement avant écriture ;
|
est rechargé. Un échec d'analyse est signalé distinctement avant écriture ;
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ Singleton {
|
||||||
readonly property var accents: ["rosewater", "flamingo", "pink", "mauve", "red", "maroon", "peach", "yellow", "green", "teal", "sky", "sapphire", "blue", "lavender"]
|
readonly property var accents: ["rosewater", "flamingo", "pink", "mauve", "red", "maroon", "peach", "yellow", "green", "teal", "sky", "sapphire", "blue", "lavender"]
|
||||||
readonly property string defaultWallpaper: "/usr/share/backgrounds/sway/Sway_Wallpaper_Blue_1920x1080.png"
|
readonly property string defaultWallpaper: "/usr/share/backgrounds/sway/Sway_Wallpaper_Blue_1920x1080.png"
|
||||||
readonly property string wallpaperBackend: (Quickshell.env("HOME") || "/home/fy59") + "/.local/bin/wallpaper-manager.py"
|
readonly property string wallpaperBackend: (Quickshell.env("HOME") || "/home/fy59") + "/.local/bin/wallpaper-manager.py"
|
||||||
|
readonly property string nightLightBackend: (Quickshell.env("HOME") || "/home/fy59") + "/.local/bin/night-light-manager.py"
|
||||||
// CONTRACT: le backend écrit cet état atomiquement après validation Sway.
|
// CONTRACT: le backend écrit cet état atomiquement après validation Sway.
|
||||||
// Le repli Mocha fonctionne avant la première génération ; aucune lecture
|
// Le repli Mocha fonctionne avant la première génération ; aucune lecture
|
||||||
// par frame ni polling. Les futurs adaptateurs lisent cet état via l'IPC.
|
// par frame ni polling. Les futurs adaptateurs lisent cet état via l'IPC.
|
||||||
|
|
@ -47,6 +48,16 @@ Singleton {
|
||||||
property string themeMode: initial.themeMode === "wallpaper" ? "wallpaper" : "manual"
|
property string themeMode: initial.themeMode === "wallpaper" ? "wallpaper" : "manual"
|
||||||
property string manualFlavor: flavors.includes(initial.manualFlavor) ? initial.manualFlavor : effectiveFlavor
|
property string manualFlavor: flavors.includes(initial.manualFlavor) ? initial.manualFlavor : effectiveFlavor
|
||||||
property var wallpaperAnalysis: initial.wallpaperAnalysis || null
|
property var wallpaperAnalysis: initial.wallpaperAnalysis || null
|
||||||
|
// CONTRACT: le choix persistant reste distinct du mode effectif ; 17F
|
||||||
|
// pourra suspendre la correction sans écraser la préférence utilisateur.
|
||||||
|
property string nightLightMode: "auto"
|
||||||
|
property string effectiveNightLightMode: "auto"
|
||||||
|
property bool nightLightSuspended: false
|
||||||
|
property int nightTemperature: 3000
|
||||||
|
property int dayTemperature: 6500
|
||||||
|
property bool nightScheduleConfigured: false
|
||||||
|
property bool nightLightBusy: false
|
||||||
|
property string nightLightError: ""
|
||||||
property bool appearanceBusy: false
|
property bool appearanceBusy: false
|
||||||
property string appearanceError: ""
|
property string appearanceError: ""
|
||||||
property bool wallpaperApplying: false
|
property bool wallpaperApplying: false
|
||||||
|
|
@ -93,6 +104,13 @@ Singleton {
|
||||||
manualFlavor = value.manualFlavor;
|
manualFlavor = value.manualFlavor;
|
||||||
wallpaperDirectory = value.wallpaperDirectory || wallpaperDirectory;
|
wallpaperDirectory = value.wallpaperDirectory || wallpaperDirectory;
|
||||||
wallpaperAnalysis = value.wallpaperAnalysis || value.analysis || null;
|
wallpaperAnalysis = value.wallpaperAnalysis || value.analysis || null;
|
||||||
|
if (value.nightLightMode === "off" || value.nightLightMode === "auto" || value.nightLightMode === "on")
|
||||||
|
nightLightMode = value.nightLightMode;
|
||||||
|
if (value.effectiveNightLightMode === "off" || value.effectiveNightLightMode === "auto" || value.effectiveNightLightMode === "on")
|
||||||
|
effectiveNightLightMode = value.effectiveNightLightMode;
|
||||||
|
if (typeof value.nightLightSuspended === "boolean") nightLightSuspended = value.nightLightSuspended;
|
||||||
|
if (Number.isInteger(value.nightTemperature)) nightTemperature = value.nightTemperature;
|
||||||
|
if (Number.isInteger(value.dayTemperature)) dayTemperature = value.dayTemperature;
|
||||||
revision = value.revision;
|
revision = value.revision;
|
||||||
appearanceError = "";
|
appearanceError = "";
|
||||||
wallpaperError = "";
|
wallpaperError = "";
|
||||||
|
|
@ -106,6 +124,72 @@ Singleton {
|
||||||
} catch (_) { return false; }
|
} catch (_) { return false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function acceptNightLight(value) {
|
||||||
|
if (!value || !["off", "auto", "on"].includes(value.nightLightMode)
|
||||||
|
|| !["off", "auto", "on"].includes(value.effectiveNightLightMode)
|
||||||
|
|| !Number.isInteger(value.nightTemperature)
|
||||||
|
|| value.dayTemperature !== 6500) return false;
|
||||||
|
nightLightMode = value.nightLightMode;
|
||||||
|
effectiveNightLightMode = value.effectiveNightLightMode;
|
||||||
|
nightLightSuspended = value.nightLightSuspended === true;
|
||||||
|
nightTemperature = value.nightTemperature;
|
||||||
|
dayTemperature = value.dayTemperature;
|
||||||
|
nightScheduleConfigured = value.scheduleConfigured === true;
|
||||||
|
nightLightError = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function acceptPublishedNightLight(payload) {
|
||||||
|
try { return acceptNightLight(JSON.parse(payload)); }
|
||||||
|
catch (_) { return false; }
|
||||||
|
}
|
||||||
|
Process {
|
||||||
|
id: nightStartup
|
||||||
|
command: ["python", appearance.nightLightBackend, "reconcile"]
|
||||||
|
running: true
|
||||||
|
stdout: StdioCollector { id: nightStartupOutput; waitForEnd: true }
|
||||||
|
stderr: StdioCollector { id: nightStartupError; waitForEnd: true }
|
||||||
|
onExited: (code, status) => {
|
||||||
|
if (code !== 0 || status !== 0) {
|
||||||
|
appearance.nightLightError = nightStartupError.text.trim() || "Réconciliation Lumière nocturne impossible";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try { appearance.acceptNightLight(JSON.parse(nightStartupOutput.text)); }
|
||||||
|
catch (_) { appearance.nightLightError = "État Lumière nocturne invalide"; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Process {
|
||||||
|
id: nightWriter
|
||||||
|
stdout: StdioCollector { id: nightOutput; waitForEnd: true }
|
||||||
|
stderr: StdioCollector { id: nightErrors; waitForEnd: true }
|
||||||
|
onExited: (code, status) => {
|
||||||
|
appearance.nightLightBusy = false;
|
||||||
|
if (code !== 0 || status !== 0) {
|
||||||
|
appearance.nightLightError = nightErrors.text.trim() || "Application Lumière nocturne impossible";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!appearance.acceptNightLight(JSON.parse(nightOutput.text)))
|
||||||
|
appearance.nightLightError = "Réponse Lumière nocturne invalide";
|
||||||
|
} catch (_) { appearance.nightLightError = "Réponse Lumière nocturne invalide"; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function runNightLight(args) {
|
||||||
|
if (nightLightBusy) return false;
|
||||||
|
nightLightBusy = true;
|
||||||
|
nightLightError = "";
|
||||||
|
nightWriter.command = ["python", nightLightBackend].concat(args);
|
||||||
|
nightWriter.running = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function setNightLightMode(mode) {
|
||||||
|
if (!["off", "auto", "on"].includes(mode) || mode === nightLightMode) return false;
|
||||||
|
return runNightLight(["set-mode", mode]);
|
||||||
|
}
|
||||||
|
function setNightTemperature(value) {
|
||||||
|
if (!Number.isInteger(value) || value < 2500 || value > 5000 || value % 100 !== 0) return false;
|
||||||
|
return runNightLight(["set-night-temperature", String(value)]);
|
||||||
|
}
|
||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: wallpaperWriter
|
id: wallpaperWriter
|
||||||
stdout: StdioCollector { id: wallpaperOutput; waitForEnd: true }
|
stdout: StdioCollector { id: wallpaperOutput; waitForEnd: true }
|
||||||
|
|
@ -170,6 +254,9 @@ Singleton {
|
||||||
effectiveAccent: effectiveAccent, effectiveWallpaper: effectiveWallpaper,
|
effectiveAccent: effectiveAccent, effectiveWallpaper: effectiveWallpaper,
|
||||||
wallpaperMode: wallpaperMode, wallpaperDirectory: wallpaperDirectory,
|
wallpaperMode: wallpaperMode, wallpaperDirectory: wallpaperDirectory,
|
||||||
themeMode: themeMode, manualFlavor: manualFlavor,
|
themeMode: themeMode, manualFlavor: manualFlavor,
|
||||||
|
nightLightMode: nightLightMode, effectiveNightLightMode: effectiveNightLightMode,
|
||||||
|
nightLightSuspended: nightLightSuspended, nightTemperature: nightTemperature,
|
||||||
|
dayTemperature: dayTemperature,
|
||||||
revision: revision });
|
revision: revision });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
# Lumière nocturne — 17E
|
||||||
|
|
||||||
|
La page **Réglages rapides → Apparence → Lumière nocturne** propose Désactivée,
|
||||||
|
Auto et Activée. `nightLightMode` est le choix persistant de l'utilisateur :
|
||||||
|
`off`, `auto` ou `on`. `effectiveNightLightMode` décrit le mode appliqué.
|
||||||
|
`nightLightSuspended` vaut `false` en 17E. Le futur Sun Mode pourra le mettre
|
||||||
|
à `true` et suspendre la correction sans modifier `nightLightMode` ; la
|
||||||
|
priorité prévue est Sun Mode > Night Light. 17E ne démarre pas Sun Mode.
|
||||||
|
|
||||||
|
Les préférences Appearance restent dans
|
||||||
|
`$XDG_CONFIG_HOME/labfy-appearance/preferences.json`, hors Git (schéma 3).
|
||||||
|
La migration conserve les champs 17D et ajoute `nightLightMode`,
|
||||||
|
`nightTemperature`, `dayTemperature`, `scheduleType=solar` et la position
|
||||||
|
locale. Les coordonnées historiques ont été migrées avant le changement de
|
||||||
|
l'unité systemd. Aucune géolocalisation distante n'est utilisée. Sur une
|
||||||
|
nouvelle installation sans position, le repli est Off et Auto indique
|
||||||
|
« Planification à configurer » ; aucune coordonnée n'est inventée.
|
||||||
|
|
||||||
|
Auto reprend exactement la planification solaire native de wlsunset 0.4.0,
|
||||||
|
avec la position locale, 3000 K la nuit et 6500 K le jour. Activée force la
|
||||||
|
température de nuit en permanence. La version locale refuse une température
|
||||||
|
haute égale à la basse ; le service démarre avec un horaire manuel valide,
|
||||||
|
puis `ExecStartPost` utilise deux SIGUSR1 documentés pour atteindre
|
||||||
|
`FORCE_LOW`. Le démarrage et chaque reprise du service réappliquent ce mode.
|
||||||
|
Désactivée arrête le service : la destruction du contrôle gamma Wayland par
|
||||||
|
wlsunset rend le contrôle au compositeur. La perception du retour neutre doit
|
||||||
|
être confirmée sur l'écran physique ; une capture ne contient pas forcément
|
||||||
|
la correction gamma.
|
||||||
|
|
||||||
|
`wlsunset.service` reste l'unique propriétaire du processus. Le launcher
|
||||||
|
`night-light-manager.py launch` lit les préférences validées, construit un
|
||||||
|
argv sans shell, puis `exec` `/usr/bin/wlsunset`. L'unité reste enabled pour
|
||||||
|
la prochaine session. En Off, le launcher quitte sans daemon et le backend
|
||||||
|
arrête l'unité dans la session courante. QuickShell n'est pas le daemon gamma :
|
||||||
|
son arrêt ou redémarrage ne coupe pas wlsunset. La commande `reconcile`
|
||||||
|
compare préférence, unité, PID et argv, répare une unité arrêtée en Auto/On
|
||||||
|
ou active en Off, et refuse une instance étrangère sans `pkill` global.
|
||||||
|
|
||||||
|
Le backend expose `current`, `validate`, `set-mode`,
|
||||||
|
`set-night-temperature` et `reconcile`, avec JSON sur stdout et erreurs sur
|
||||||
|
stderr. Une écriture de préférences passe par fichier temporaire, flush,
|
||||||
|
fsync et replace. Si l'application ou la publication échoue, le backend
|
||||||
|
restaure les JSON précédents et le service. Les températures de nuit admises
|
||||||
|
vont de 2500 à 5000 K par pas de 100 K ; l'interface applique la valeur avec
|
||||||
|
un bouton, sans relancer le service pendant le glissement. La température de
|
||||||
|
jour reste 6500 K en V1. Aucun polling de l'unité n'est effectué.
|
||||||
|
|
||||||
|
Changer la lumière nocturne ne modifie pas le thème, l'accent ou le fond
|
||||||
|
d'écran. Les champs effectifs ajoutés à `effective.json` ne prétendent pas
|
||||||
|
connaître la température instantanée pendant une transition wlsunset.
|
||||||
|
|
@ -65,6 +65,8 @@ reste séparé dans `generated/wallpaper.conf` ; voir
|
||||||
préserve les champs wallpaper du schéma effectif 3 lors d'un changement de
|
préserve les champs wallpaper du schéma effectif 3 lors d'un changement de
|
||||||
thème. L'algorithme Auto, ses seuils et la migration sont documentés dans
|
thème. L'algorithme Auto, ses seuils et la migration sont documentés dans
|
||||||
[AUTO_THEME.md](AUTO_THEME.md).
|
[AUTO_THEME.md](AUTO_THEME.md).
|
||||||
|
La lumière nocturne 17E est indépendante du flavor et du fond ; son service,
|
||||||
|
ses modes et ses préférences sont décrits dans [NIGHT_LIGHT.md](NIGHT_LIGHT.md).
|
||||||
Le repli versionné `sway/theme-default.conf` fournit Mocha même avant toute
|
Le repli versionné `sway/theme-default.conf` fournit Mocha même avant toute
|
||||||
génération. Ses quelques variables sont un artefact dérivé, pas une palette
|
génération. Ses quelques variables sont un artefact dérivé, pas une palette
|
||||||
complète indépendante. `sway/style` inclut le repli, puis un glob de fichier
|
complète indépendante. `sway/style` inclut le repli, puis un glob de fichier
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,13 @@ PartOf=graphical-session.target
|
||||||
After=graphical-session.target
|
After=graphical-session.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
ExecStart=/usr/bin/wlsunset -l 48.85 -L 2.35 -t 3000 -T 6500
|
Type=exec
|
||||||
|
# CONTRACT: le launcher lit les préférences locales puis exec wlsunset ;
|
||||||
|
# systemd garde ainsi le PID et le contrôle exclusif de la correction gamma.
|
||||||
|
ExecStart=/usr/bin/python /home/fy59/.local/bin/night-light-manager.py launch
|
||||||
|
# WHY: wlsunset 0.4.0 refuse -T == -t ; son SIGUSR1 documenté permet
|
||||||
|
# AUTO -> FORCE_HIGH -> FORCE_LOW lorsque le mode ON est demandé.
|
||||||
|
ExecStartPost=/usr/bin/python /home/fy59/.local/bin/night-light-manager.py post-start
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|
|
||||||
110
tests/test_night_light_manager.py
Normal file
110
tests/test_night_light_manager.py
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
"""Contrats Night Light sur préférences et service simulés, sans localisation réelle."""
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
SOURCE = Path(__file__).resolve().parents[1] / 'bin/.local/bin/night-light-manager.py'
|
||||||
|
SPEC = importlib.util.spec_from_file_location('night_light_manager', SOURCE)
|
||||||
|
night = importlib.util.module_from_spec(SPEC)
|
||||||
|
SPEC.loader.exec_module(night)
|
||||||
|
|
||||||
|
|
||||||
|
class NightLightTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.root = Path(self.temp.name)
|
||||||
|
self.patchers = [mock.patch.object(night, 'CONFIG', self.root / 'preferences.json'),
|
||||||
|
mock.patch.object(night, 'STATE', self.root / 'effective.json'),
|
||||||
|
mock.patch.object(night, 'LOCK', self.root / 'wallpaper.lock')]
|
||||||
|
for patcher in self.patchers:
|
||||||
|
patcher.start()
|
||||||
|
self.pref = {'version': 2, 'themeMode': 'manual', 'manualFlavor': 'mocha',
|
||||||
|
'wallpaperDirectory': '/fixture', 'nightLightMode': 'auto',
|
||||||
|
'nightTemperature': 3000, 'dayTemperature': 6500,
|
||||||
|
'scheduleType': 'solar', 'latitude': '12.5', 'longitude': '-8.25'}
|
||||||
|
night.atomic_json(night.CONFIG, self.pref)
|
||||||
|
night.atomic_json(night.STATE, {'effectiveFlavor': 'mocha', 'revision': 5})
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
for patcher in reversed(self.patchers):
|
||||||
|
patcher.stop()
|
||||||
|
self.temp.cleanup()
|
||||||
|
|
||||||
|
def test_migration_preserves_other_preferences(self):
|
||||||
|
result = night.normalized(self.pref)
|
||||||
|
self.assertEqual(result['version'], 3)
|
||||||
|
self.assertEqual(result['themeMode'], 'manual')
|
||||||
|
self.assertEqual(result['wallpaperDirectory'], '/fixture')
|
||||||
|
|
||||||
|
def test_auto_argv_uses_solar_and_baseline(self):
|
||||||
|
self.assertEqual(night.argv(self.pref), ['/usr/bin/wlsunset', '-l', '12.5', '-L', '-8.25',
|
||||||
|
'-t', '3000', '-T', '6500'])
|
||||||
|
|
||||||
|
def test_on_argv_uses_manual_schedule_for_force_low(self):
|
||||||
|
pref = {**self.pref, 'nightLightMode': 'on'}
|
||||||
|
self.assertEqual(night.argv(pref), ['/usr/bin/wlsunset', '-S', '06:00', '-s', '18:00',
|
||||||
|
'-t', '3000', '-T', '6500'])
|
||||||
|
|
||||||
|
def test_off_has_no_process_argv(self):
|
||||||
|
self.assertIsNone(night.argv({**self.pref, 'nightLightMode': 'off'}))
|
||||||
|
|
||||||
|
def test_invalid_mode_and_temperature(self):
|
||||||
|
for mode in ('invalid', '', None):
|
||||||
|
with self.subTest(mode=mode), self.assertRaises(ValueError):
|
||||||
|
night.normalized({**self.pref, 'nightLightMode': mode})
|
||||||
|
for value in ('3500', '', None, float('nan'), 2400, 5100, 3550, True):
|
||||||
|
with self.subTest(value=value), self.assertRaises(ValueError):
|
||||||
|
night.temperature(value)
|
||||||
|
|
||||||
|
def test_fresh_install_has_no_invented_coordinates(self):
|
||||||
|
self.assertEqual(night.normalized({})['nightLightMode'], 'off')
|
||||||
|
with self.assertRaisesRegex(ValueError, 'Planification à configurer'):
|
||||||
|
night.normalized({'nightLightMode': 'auto'})
|
||||||
|
|
||||||
|
def test_atomic_write_keeps_previous_on_replace_failure(self):
|
||||||
|
before = night.CONFIG.read_bytes()
|
||||||
|
with mock.patch.object(night.os, 'replace', side_effect=OSError('échec')):
|
||||||
|
with self.assertRaises(OSError):
|
||||||
|
night.atomic_json(night.CONFIG, {'bad': True})
|
||||||
|
self.assertEqual(night.CONFIG.read_bytes(), before)
|
||||||
|
|
||||||
|
def test_change_rolls_back_preferences_and_state(self):
|
||||||
|
before_pref = night.CONFIG.read_bytes()
|
||||||
|
before_state = night.STATE.read_bytes()
|
||||||
|
with mock.patch.object(night, 'apply_service', side_effect=[RuntimeError('service'), None]) as apply:
|
||||||
|
with self.assertRaisesRegex(RuntimeError, 'service'):
|
||||||
|
night.change(mode='off')
|
||||||
|
self.assertEqual(apply.call_count, 2)
|
||||||
|
self.assertEqual(night.CONFIG.read_bytes(), before_pref)
|
||||||
|
self.assertEqual(night.STATE.read_bytes(), before_state)
|
||||||
|
|
||||||
|
def test_change_keeps_appearance_fields(self):
|
||||||
|
with mock.patch.object(night, 'apply_service'), mock.patch.object(night, 'publish'):
|
||||||
|
result = night.change(mode='off')
|
||||||
|
self.assertEqual(result['nightLightMode'], 'off')
|
||||||
|
self.assertEqual(json.loads(night.STATE.read_text())['effectiveFlavor'], 'mocha')
|
||||||
|
self.assertEqual(json.loads(night.CONFIG.read_text())['manualFlavor'], 'mocha')
|
||||||
|
|
||||||
|
def test_reconcile_repairs_missing_service(self):
|
||||||
|
with mock.patch.object(night, 'verify', side_effect=RuntimeError('absent')), \
|
||||||
|
mock.patch.object(night, 'unit_state', return_value=(False, 0)), \
|
||||||
|
mock.patch.object(night, 'wlsunset_pids', return_value=[]), \
|
||||||
|
mock.patch.object(night, 'apply_service') as apply:
|
||||||
|
result = night.reconcile()
|
||||||
|
apply.assert_called_once()
|
||||||
|
self.assertEqual(result['effectiveNightLightMode'], 'auto')
|
||||||
|
|
||||||
|
def test_future_suspension_preserves_preference(self):
|
||||||
|
pref = night.normalized(self.pref)
|
||||||
|
state = {'nightLightSuspended': True}
|
||||||
|
result = night.snapshot(pref, state)
|
||||||
|
self.assertEqual(result['nightLightMode'], 'auto')
|
||||||
|
self.assertEqual(result['effectiveNightLightMode'], 'off')
|
||||||
|
self.assertEqual(night.effective('on', True), 'off')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
|
|
@ -65,7 +65,7 @@ class WallpaperManagerTest(unittest.TestCase):
|
||||||
manager.validate_image(path)
|
manager.validate_image(path)
|
||||||
chosen = manager.set_directory(self.root.as_uri())
|
chosen = manager.set_directory(self.root.as_uri())
|
||||||
self.assertEqual(chosen['wallpaperDirectory'], str(self.root))
|
self.assertEqual(chosen['wallpaperDirectory'], str(self.root))
|
||||||
self.assertEqual(json.loads(manager.PREFERENCES.read_text())['version'], 2)
|
self.assertEqual(json.loads(manager.PREFERENCES.read_text())['version'], 3)
|
||||||
|
|
||||||
def test_atomic_replace_keeps_old_file_on_write_failure(self):
|
def test_atomic_replace_keeps_old_file_on_write_failure(self):
|
||||||
target = self.root / 'atomic.txt'
|
target = self.root / 'atomic.txt'
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue