1# controller.py -- Redshift child process controller 2# This file is part of Redshift. 3 4# Redshift is free software: you can redistribute it and/or modify 5# it under the terms of the GNU General Public License as published by 6# the Free Software Foundation, either version 3 of the License, or 7# (at your option) any later version. 8 9# Redshift is distributed in the hope that it will be useful, 10# but WITHOUT ANY WARRANTY; without even the implied warranty of 11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12# GNU General Public License for more details. 13 14# You should have received a copy of the GNU General Public License 15# along with Redshift. If not, see <http://www.gnu.org/licenses/>. 16 17# Copyright (c) 2013-2017 Jon Lund Steffensen <jonlst@gmail.com> 18 19import os 20import re 21import fcntl 22import signal 23 24import gi 25gi.require_version('GLib', '2.0') 26 27from gi.repository import GLib, GObject 28 29from . import defs 30 31 32class RedshiftController(GObject.GObject): 33 """GObject wrapper around the Redshift child process.""" 34 35 __gsignals__ = { 36 'inhibit-changed': (GObject.SIGNAL_RUN_FIRST, None, (bool,)), 37 'temperature-changed': (GObject.SIGNAL_RUN_FIRST, None, (int,)), 38 'period-changed': (GObject.SIGNAL_RUN_FIRST, None, (str,)), 39 'location-changed': (GObject.SIGNAL_RUN_FIRST, None, (float, float)), 40 'error-occured': (GObject.SIGNAL_RUN_FIRST, None, (str,)), 41 'stopped': (GObject.SIGNAL_RUN_FIRST, None, ()), 42 } 43 44 def __init__(self, args): 45 """Initialize controller and start child process. 46 47 The parameter args is a list of command line arguments to pass on to 48 the child process. The "-v" argument is automatically added. 49 """ 50 GObject.GObject.__init__(self) 51 52 # Initialize state variables 53 self._inhibited = False 54 self._temperature = 0 55 self._period = 'Unknown' 56 self._location = (0.0, 0.0) 57 58 # Start redshift with arguments 59 args.insert(0, os.path.join(defs.BINDIR, 'redshift')) 60 if '-v' not in args: 61 args.insert(1, '-v') 62 63 # Start child process with C locale so we can parse the output 64 env = os.environ.copy() 65 for key in ('LANG', 'LANGUAGE', 'LC_ALL', 'LC_MESSAGES'): 66 env[key] = 'C' 67 self._process = GLib.spawn_async( 68 args, envp=['{}={}'.format(k, v) for k, v in env.items()], 69 flags=GLib.SPAWN_DO_NOT_REAP_CHILD, 70 standard_output=True, standard_error=True) 71 72 # Wrap remaining contructor in try..except to avoid that the child 73 # process is not closed properly. 74 try: 75 # Handle child input 76 # The buffer is encapsulated in a class so we 77 # can pass an instance to the child callback. 78 class InputBuffer(object): 79 buf = '' 80 81 self._input_buffer = InputBuffer() 82 self._error_buffer = InputBuffer() 83 self._errors = '' 84 85 # Set non blocking 86 fcntl.fcntl( 87 self._process[2], fcntl.F_SETFL, 88 fcntl.fcntl(self._process[2], fcntl.F_GETFL) | os.O_NONBLOCK) 89 90 # Add watch on child process 91 GLib.child_watch_add( 92 GLib.PRIORITY_DEFAULT, self._process[0], self._child_cb) 93 GLib.io_add_watch( 94 self._process[2], GLib.PRIORITY_DEFAULT, GLib.IO_IN, 95 self._child_data_cb, (True, self._input_buffer)) 96 GLib.io_add_watch( 97 self._process[3], GLib.PRIORITY_DEFAULT, GLib.IO_IN, 98 self._child_data_cb, (False, self._error_buffer)) 99 100 # Signal handler to relay USR1 signal to redshift process 101 def relay_signal_handler(signal): 102 os.kill(self._process[0], signal) 103 return True 104 105 GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGUSR1, 106 relay_signal_handler, signal.SIGUSR1) 107 except: 108 self.termwait() 109 raise 110 111 @property 112 def inhibited(self): 113 """Current inhibition state.""" 114 return self._inhibited 115 116 @property 117 def temperature(self): 118 """Current screen temperature.""" 119 return self._temperature 120 121 @property 122 def period(self): 123 """Current period of day.""" 124 return self._period 125 126 @property 127 def location(self): 128 """Current location.""" 129 return self._location 130 131 def set_inhibit(self, inhibit): 132 """Set inhibition state.""" 133 if inhibit != self._inhibited: 134 self._child_toggle_inhibit() 135 136 def _child_signal(self, sg): 137 """Send signal to child process.""" 138 os.kill(self._process[0], sg) 139 140 def _child_toggle_inhibit(self): 141 """Sends a request to the child process to toggle state.""" 142 self._child_signal(signal.SIGUSR1) 143 144 def _child_cb(self, pid, status, data=None): 145 """Called when the child process exists.""" 146 147 # Empty stdout and stderr 148 for f in (self._process[2], self._process[3]): 149 while True: 150 buf = os.read(f, 256).decode('utf-8') 151 if buf == '': 152 break 153 if f == self._process[3]: # stderr 154 self._errors += buf 155 156 # Check exit status of child 157 try: 158 GLib.spawn_check_exit_status(status) 159 except GLib.GError: 160 self.emit('error-occured', self._errors) 161 162 GLib.spawn_close_pid(self._process[0]) 163 self.emit('stopped') 164 165 def _child_key_change_cb(self, key, value): 166 """Called when the child process reports a change of internal state.""" 167 168 def parse_coord(s): 169 """Parse coordinate like `42.0 N` or `91.5 W`.""" 170 v, d = s.split(' ') 171 return float(v) * (1 if d in 'NE' else -1) 172 173 if key == 'Status': 174 new_inhibited = value != 'Enabled' 175 if new_inhibited != self._inhibited: 176 self._inhibited = new_inhibited 177 self.emit('inhibit-changed', new_inhibited) 178 elif key == 'Color temperature': 179 new_temperature = int(value.rstrip('K'), 10) 180 if new_temperature != self._temperature: 181 self._temperature = new_temperature 182 self.emit('temperature-changed', new_temperature) 183 elif key == 'Period': 184 new_period = value 185 if new_period != self._period: 186 self._period = new_period 187 self.emit('period-changed', new_period) 188 elif key == 'Location': 189 new_location = tuple(parse_coord(x) for x in value.split(', ')) 190 if new_location != self._location: 191 self._location = new_location 192 self.emit('location-changed', *new_location) 193 194 def _child_stdout_line_cb(self, line): 195 """Called when the child process outputs a line to stdout.""" 196 if line: 197 m = re.match(r'([\w ]+): (.+)', line) 198 if m: 199 key = m.group(1) 200 value = m.group(2) 201 self._child_key_change_cb(key, value) 202 203 def _child_data_cb(self, f, cond, data): 204 """Called when the child process has new data on stdout/stderr.""" 205 stdout, ib = data 206 ib.buf += os.read(f, 256).decode('utf-8') 207 208 # Split input at line break 209 while True: 210 first, sep, last = ib.buf.partition('\n') 211 if sep == '': 212 break 213 ib.buf = last 214 if stdout: 215 self._child_stdout_line_cb(first) 216 else: 217 self._errors += first + '\n' 218 219 return True 220 221 def terminate_child(self): 222 """Send SIGINT to child process.""" 223 self._child_signal(signal.SIGINT) 224 225 def kill_child(self): 226 """Send SIGKILL to child process.""" 227 self._child_signal(signal.SIGKILL) 228