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