# -*- python -*-

# This example adjusts the the colours to make it easier to go to bed
# around a scheduled time, for each weekday.


# Copyright © 2014  Mattias Andrée (maandree@member.fsf.org)
# 
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# 
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
# 
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import time
import datetime


# Geographical coodinates.
# ("Kristall, vertikal accent i glas och stål" (Crystal, vertical accent
# in glass and steal) in this example. A glass obelisk, lit from the inside
# with adjustable colours and a default colour of 5600 K, in the middle
# of a hyperelliptic roundabout.)
latitude, longitude = 59.3326, 18.0652


# The time for each weekday you go to bed. The first value is the
# time to start preparing the for sleep and the second value is the
# time the monitors should be fully adjusted for sleep.
time_sleep_monday    = ('21:00', '24:00')
time_sleep_tuesday   = ('21:00', '24:00')
time_sleep_wednesday = ('21:00', '24:00')
time_sleep_thursday  = ('21:00', '24:00')
time_sleep_friday    = ('21:00', '24:00')
time_sleep_saturday  = ('23:00', '26:00')
time_sleep_sunday    = ('23:00', '26:00')
# It is allowed to have values above and including 24:00, these
# values are interprets as that time (minus 24 hours) the next day.

# The time for each weekday you wake up. The first value is the time
# to start adjusting the colours back to normal node, and the second
# value is the time the adjustment should be back to fully normal.
time_wakeup_monday    = ('06:00', '07:00')
time_wakeup_tuesday   = ('06:00', '07:00')
time_wakeup_wednesday = ('06:00', '07:00')
time_wakeup_thursday  = ('06:00', '07:00')
time_wakeup_friday    = ('06:00', '07:00')
time_wakeup_saturday  = ('13:00', '14:00')
time_wakeup_sunday    = ('13:00', '14:00')


# The (zero-based) index of the monitors (CRTC:s) to apply
# settings to. An empty list means that all monitors are used,
# but all monitors will have the same settings.
monitors = []


# Gamma correction for the red, green and blue components,
# respectively, for each monitor,
gamma_red   = [1]
gamma_green = [1]
gamma_blue  = [1]

# The colour temperature during the day, night, during
# sleep and the default, respectively.
temperature_day     = 5500
temperature_night   = 3500
temperature_sleep   = 1000
temperature_default = 6500

# The brightness during the day, night, during sleep
# and the default respectively.
brightness_day     = 1
brightness_night   = 1
brightness_sleep   = 0.2
brightness_default = 1


# Method for applying colour curves.
apply_curves = randr
#apply_curves = vidmode
if ttymode:
    apply_curves = drm

# Method used to get the degree to which it is day.
get_dayness = lambda : sun(latitude, longitude)



wait_period = 1
'''
:float  The number of seconds to wait before invoking `periodically` again
'''

fadein_time = 20
'''
:float?  The number of seconds used to fade in on start, `None` for no fading
'''

fadeout_time = 10
'''
:float?  The number of seconds used to fade out on exit, `None` for no fading
'''

fadein_steps = 20 * fadein_time if fadein_time is not None else None
'''
:int  The number of steps in the fade in phase, if any
'''

fadeout_steps = 20 * fadeout_time if fadeout_time is not None else None
'''
:int  The number of steps in the fade out phase, if any
'''


# Time constants.
ONE_DAY = 24 * 60 * 60
ONE_WEEK = 7 * ONE_DAY


# Combine the time points into a matrix.
times = (time_sleep_monday    + time_wakeup_tuesday,
         time_sleep_tuesday   + time_wakeup_wednesday,
         time_sleep_wednesday + time_wakeup_thursday,
         time_sleep_thursday  + time_wakeup_friday,
         time_sleep_friday    + time_wakeup_saturday,
         time_sleep_saturday  + time_wakeup_sunday,
         time_sleep_sunday    + time_wakeup_monday)

def interpret_time(t):
    '''
    Convert a text representation of a time point to a float
    point value of the number of seconds
    
    @param   t:str   The time as text
    @return  :float  The time as floating point
    '''
    t = [float(t_) for t_ in t.split(':')]
    while len(t) > 3:
        t.append(0)
    return sum([v * 60 ** (2 - i) for i, v in enumerate(t)])

def monotonic_time(ts):
    '''
    Ensure that each time points in a sequence is at least
    as late as the previous time
    
    @param   ts:list<float>  The time point sequence
    @return  :list<float>    The time point sequence as an increasing sequence
    '''
    rc = [ts[0]]
    for t in ts[1:]:
        if t < rc[-1]:
            t += rc[-1] - (rc[-1] % ONE_DAY)
            if t < rc[-1]:
                t += ONE_DAY
        rc.append(t)
    return rc

times = [monotonic_time([interpret_time(t) for t in ts]) for ts in times]


# Convert time point matrix to a vector.
timepoints = []
for weekday in range(len(times)):
    weekday_ = weekday * ONE_DAY
    ts = times[weekday]
    for ti in range(len(ts)):
        t = (ts[ti] + weekday_) % ONE_WEEK
        timepoints.append((t, ti))
timepoints.sort(key = lambda x : x[0])
timepoints.insert(0, (timepoints[-1][0] - ONE_WEEK, timepoints[-1][1]))
timepoints.append((timepoints[1][0] + ONE_WEEK, timepoints[1][1]))


def get_bedness(time):
    '''
    Calculate to what degree the adjustments should be tuned to bedtime mode
    
    @param   time:float  The number of seconds in the time modulo the a week
    @return  :float      To what degree the adjustments should be tuned to bedtime mode
    '''
    for i in range(len(timepoints) - 1):
        if timepoints[i][0] <= time <= timepoints[i + 1][0]:
            break
    (a, p), (b, _) = timepoints[i], timepoints[i + 1]
    weight = (time - a) / (b - a)
    if p == 0:  return weight
    if p == 1:  return 1
    if p == 2:  return 1 - weight
    return 0



last_dayness, last_bedness = -1, -1
def periodically(year, month, day, hour, minute, second, weekday, fade):
    '''
    Invoked periodically
    
    If you want to control at what to invoke this function next time
    you can set the value of the global variable `wait_period` to the
    number of seconds to wait before invoking this function again.
    The value does not need to be an integer.
    
    @param  year:int     The year
    @param  month:int    The month, 1 = January, 12 = December
    @param  day:int      The day, minimum value is 1, probable maximum value is 31 (*)
    @param  hour:int     The hour, minimum value is 0, maximum value is 23
    @param  minute:int   The minute, minimum value is 0, maximum value is 59
    @param  second:int   The second, minimum value is 0, probable maximum value is 60 (**)
    @param  weekday:int  The weekday, 1 = Monday, 7 = Sunday
    @param  fade:float?  Blueshift can use this function to fade into a state when it start
                         or exits. `fade` can either be negative, zero or positive or `None`,
                         but the magnitude of value cannot exceed 1. When Blueshift starts,
                         this function will be invoked multiple with the time parameters
                         of the time it is invoked and each time `fade` will increase towards
                         1, starting at 0, when the value is 1, the settings should be applied
                         to 100 %. After this this function will be invoked once again with
                         `fade` being `None`. When Blueshift exits the same behaviour is used
                         except, `fade` decrease towards -1 but start slightly below 0, when
                         -1 is reached all settings should be normal. Then Blueshift will NOT
                         invoke this function with `fade` being `None`, instead it will by
                         itself revert all settings and quit.
    
    (*)  Can be exceeded if the calendar system is changed, like in 1712-(02)Feb-30
    (**) See https://en.wikipedia.org/wiki/Leap_second
    '''
    global last_dayness, last_bedness
    
    tzoff = (datetime.datetime.now().hour - datetime.datetime.utcnow().hour) * 60 * 60
    tzoff += (datetime.datetime.now().minute - datetime.datetime.utcnow().minute) * 60
    now = time.time() + tzoff
    h = int((now / (60 * 60)) % 24)
    if h < hour:
        weekday += 1
    
    dayness = get_dayness()
    bedness = get_bedness((weekday - 1) * ONE_DAY + (now % ONE_DAY))
    
    # Do not apply new adjustments if nothing has changed.
    if (fade is None) and (dayness == last_dayness) and (bedness == last_bedness):
        return
    last_dayness, last_bedness = dayness, bedness
    
    # Calculate temperature and brightness.
    temperature_ = temperature_day * dayness + temperature_night * (1 - dayness)
    brightness_  =  brightness_day * dayness +  brightness_night * (1 - dayness)
    temperature_ = temperature_sleep * bedness + temperature_ * (1 - bedness)
    brightness_  =  brightness_sleep * bedness +  brightness_ * (1 - bedness)
    if fade is not None:
        alpha = abs(fade)
        temperature_ = temperature_ * alpha + temperature_default * (1 - alpha)
        brightness_  =  brightness_ * alpha +  brightness_default * (1 - alpha)
    
    # Remove settings from last run.
    start_over()
    
    # Apply colour temperature using raw CIE 1964 10 degree CMF data with interpolation.
    temperature(temperature_, lambda t : clip_whitepoint(divide_by_maximum(cmf_10deg(t))))
    
    # Apply colour brightness using the CIE xyY colour space.
    cie_brightness(brightness_)
    
    # Store calculates so that they can be reused for each monitor
    stored = store()
    
    for m in range(max(1, len(monitors))):
        gamma_red_   = gamma_red  [m % len(gamma_red)]
        gamma_green_ = gamma_green[m % len(gamma_green)]
        gamma_blue_  = gamma_blue [m % len(gamma_blue)]
        
        # Reuse stored calculations.
        restore(stored)
        
        # Apply gamma correction to monitor.
        gamma(gamma_red_, gamma_green_, gamma_blue_)
        
        # Flush settings to monitor.
        if len(monitors) == 0:
            apply_curves()
        else:
            apply_curves(monitors[m % len(monitors)])



def reset():
    '''
    Invoked to reset the displays
    '''
    for m in range(max(1, len(monitors))):
        gamma_red_   = gamma_red  [m % len(gamma_red)]
        gamma_green_ = gamma_green[m % len(gamma_green)]
        gamma_blue_  = gamma_blue [m % len(gamma_blue)]
        
        # Remove settings from last run.
        start_over()
        
        # Apply gamma correction to monitor.
        gamma(gamma_red_, gamma_green_, gamma_blue_)
        
        # Flush settings to monitor.
        if len(monitors) == 0:
            apply_curves()
        else:
            apply_curves(monitors[m % len(monitors)])