Пример #1
0
class GoogleCalendar(ImageContent):
    """A monthly calendar backed by the Google Calendar API."""
    def __init__(self, geocoder):
        self._local_time = LocalTime(geocoder)

    def _days_range(self, start, end):
        """Returns a list of days of the month between two datetimes."""

        # Exclude the exact end time to avoid counting the last day if
        # the end falls exactly on midnight.
        end -= timedelta(microseconds=1)

        return range(start.day, end.day + 1)

    def _event_counts(self, time, user):
        """Retrieves a daily count of events using the Google Calendar API."""

        # Create an authorized connection to the API.
        storage = GoogleCalendarStorage(user.id)
        credentials = storage.get()
        if not credentials:
            error('No valid Google Calendar credentials.')
            return Counter()
        authed_http = credentials.authorize(http=build_http())
        service = discovery.build(API_NAME,
                                  API_VERSION,
                                  http=authed_http,
                                  cache_discovery=False)

        # Process calendar events for each day of the current month.
        first_date = time.replace(day=1,
                                  hour=0,
                                  minute=0,
                                  second=0,
                                  microsecond=0)
        _, last_day = monthrange(time.year, time.month)
        last_date = first_date.replace(day=last_day)
        page_token = None
        event_counts = Counter()
        while True:
            # Request this month's events.
            request = service.events().list(calendarId=CALENDAR_ID,
                                            timeMin=first_date.isoformat(),
                                            timeMax=last_date.isoformat(),
                                            singleEvents=True,
                                            pageToken=page_token)
            try:
                response = request.execute()
            except HttpAccessTokenRefreshError as e:
                warning('Google Calendar request failed: %s' % e)
                return Counter()

            # Iterate over the events from the current page.
            for event in response['items']:
                try:
                    # Count regular events.
                    start = parse(event['start']['dateTime'])
                    end = parse(event['end']['dateTime'])
                    for day in self._days_range(start, end):
                        event_counts[day] += 1
                except KeyError:
                    pass

                try:
                    # Count all-day events.
                    start = datetime.strptime(event['start']['date'],
                                              '%Y-%m-%d')
                    end = datetime.strptime(event['end']['date'], '%Y-%m-%d')
                    for day in self._days_range(start, end):
                        event_counts[day] += 1
                except KeyError:
                    pass

            # Move to the next page or stop.
            page_token = response.get('nextPageToken')
            if not page_token:
                break

        return event_counts

    def image(self, user, width, height):
        """Generates an image with a calendar view."""

        # Show a calendar relative to the current date.
        try:
            time = self._local_time.now(user)
        except DataError as e:
            raise ContentError(e)

        # Get the number of events per day from the API.
        event_counts = self._event_counts(time, user)

        # Create a blank image.
        image = Image.new(mode='RGB',
                          size=(width, height),
                          color=BACKGROUND_COLOR)
        draw = Draw(image)

        # Get this month's calendar.
        try:
            firstweekday = WEEK_DAYS[user.get('first_week_day')]
        except KeyError:
            firstweekday = SUNDAY
        calendar = Calendar(firstweekday=firstweekday)
        weeks = calendar.monthdayscalendar(time.year, time.month)

        # Determine the spacing of the days in the image.
        x_stride = width // (DAYS_IN_WEEK + 1)
        y_stride = height // (len(weeks) + 1)

        # Draw each week in a row.
        for week_index in range(len(weeks)):
            week = weeks[week_index]

            # Draw each day in a column.
            for day_index in range(len(week)):
                day = week[day_index]

                # Ignore days from other months.
                if day == 0:
                    continue

                # Determine the position of this day in the image.
                x = (day_index + 1) * x_stride
                y = (week_index + 1) * y_stride

                # Mark the current day with a squircle.
                if day == time.day:
                    squircle = Image.open(SQUIRCLE_FILE).convert(mode='RGBA')
                    squircle_xy = (x - squircle.width // 2,
                                   y - squircle.height // 2)
                    draw.bitmap(squircle_xy, squircle, HIGHLIGHT_COLOR)
                    number_color = TODAY_COLOR
                    event_color = TODAY_COLOR
                else:
                    number_color = NUMBER_COLOR
                    event_color = HIGHLIGHT_COLOR

                # Draw the day of the month number.
                number = str(day)
                draw_text(number,
                          SUBVARIO_CONDENSED_MEDIUM,
                          number_color,
                          xy=(x, y - NUMBER_Y_OFFSET),
                          image=image)

                # Draw a dot for each event.
                num_events = min(MAX_EVENTS, event_counts[day])
                dot = Image.open(DOT_FILE).convert(mode='RGBA')
                if num_events > 0:
                    events_width = (num_events * dot.width +
                                    (num_events - 1) * DOT_MARGIN)
                    for event_index in range(num_events):
                        event_offset = (event_index *
                                        (dot.width + DOT_MARGIN) -
                                        events_width // 2)
                        dot_xy = [
                            x + event_offset, y + DOT_OFFSET - dot.width // 2
                        ]
                        draw.bitmap(dot_xy, dot, event_color)

        return image
Пример #2
0
class City(ImageContent):
    """A dynamic city scene that changes with the weather and other factors."""

    def __init__(self, geocoder):
        self._local_time = LocalTime(geocoder)
        self._sun = Sun(geocoder)
        self._weather = Weather(geocoder)

    def _day_of_year(self, user):
        """Returns the current day of the year in the users's time zone."""

        return self._local_time.now(user).timetuple().tm_yday

    def _modulo_3_0(self, user):
        """Returns True if the current day of the year modulo 3 is 0."""

        return self._day_of_year(user) % 3 == 0

    def _modulo_3_1(self, user):
        """Returns True if the current day of the year modulo 3 is 1."""

        return self._day_of_year(user) % 3 == 1

    def _modulo_3_2(self, user):
        """Returns True if the current day of the year modulo 3 is 2."""

        return self._day_of_year(user) % 3 == 2

    def _layers(self):
        """The list of layers making up the city scene. Each layer is a
        dictionary with a combination of the following keys.

        Exactly one of...
             'condition': A function that needs to evaluate to True for this
                          layer to be drawn.
         'not_condition': A function that needs to evaluate to False for this
                          layer to be drawn.
         'and_condition': A list of functions that all have to evaluate to True
                          for this layer to be drawn.
          'or_condition': A list of functions where at least one has to
                          evaluate to True for this layer to be drawn.
        'else_condition': A list of file paths (identifying layers) that have
                          to not have been drawn for this layer to be drawn.

        And optionally...
           'probability': The probability in percent for this layer to be
                          drawn.

        Either a layer group...
                'layers': A list of layer dictionaries to be drawn recursively.

        Or layer content...
                  'file': The path of the image file for this layer relative to
                          ASSETS_DIR.

        With a fixed position...
                    'xy': A tuple defining the top left corner of this layer.

        Or a dynamic position...
          'xy_transform': A function that evaluates to a tuple defining the top
                          left corner of this layer.
               'xy_data': The argument passed to the function specified in
                          'xy_transform'.
        """

        return [{
            'condition': self._sun.is_daylight,
            'layers': [
                {
                    'file': 'day/environment/water-day.gif',
                    'xy': (-640, -384),
                    'or_condition': [self._weather.is_clear,
                                     self._weather.is_partly_cloudy,
                                     self._weather.is_cloudy,
                                     self._weather.is_foggy]
                },
                {
                    'file': 'day/environment/water-flat-day.gif',
                    'xy': (-640, -384),
                    'or_condition': [self._weather.is_rainy,
                                     self._weather.is_snowy]
                },
                {
                    'file': 'day/environment/isle-day.gif',
                    'xy': (-4, 24)
                },
                {
                    'file': 'day/blocks/bldg-facstdo-day.gif',
                    'xy': (262, 9)
                },
                {
                    'file': 'day/misc/lightpole-day.gif',
                    'xy': (130, 5)
                },
                {
                    'file': 'day/blocks/bldg-verylittlegravitas-day.gif',
                    'xy': (188, 18)
                },
                {
                    'file': 'day/blocks/block-D-day.gif',
                    'xy': (74, 59)
                },
                {
                    'file': 'day/vehicles/van2-247-yp-day.gif',
                    'xy': (156, 116),
                    'probability': 50
                },
                {
                    'file': 'day/misc/streetlight-xp-day.gif',
                    'xy': (314, 11)
                },
                {
                    'file': 'day/blocks/block-F-day.gif',
                    'xy': (418, 6)
                },
                {
                    'file': 'day/blocks/bldg-home-day.gif',
                    'xy': (422, 36)
                },
                {
                    'file': 'day/vehicles/boat3-yp-day.gif',
                    'xy': (590, 87),
                    'probability': 50
                },
                {
                    'file': ('day/characters/blockbob/'
                             'blockbob-driving-xp-day.gif'),
                    'xy': (418, 109),
                    'probability': 50,
                    'not_condition': self._weather.is_rainy
                },
                {
                    'file': ('day/characters/blockbob/'
                             'blockbob-driving-xp-day-rain.gif'),
                    'xy': (418, 93),
                    'probability': 50,
                    'condition': self._weather.is_rainy
                },
                {
                    'file': 'day/environment/fog1-day.gif',
                    'xy': (-640, -384),
                    'condition': self._weather.is_foggy
                },
                {
                    'file': 'day/blocks/bldg-robosuper-day.gif',
                    'xy': (540, 116)
                },
                {
                    'file': 'day/blocks/block-A/block-A-day.gif',
                    'xy': (200, 6),
                    'not_condition': self._weather.is_rainy
                },
                {
                    'file': 'day/blocks/block-A/block-A-day-rain.gif',
                    'xy': (200, 6),
                    'condition': self._weather.is_rainy
                },
                {
                    'file': 'day/characters/blockbob/blockbob-sitting-day.gif',
                    'xy': (276, 78),
                    'else_condition': [
                        'day/characters/blockbob/blockbob-driving-xp-day.gif',
                        ('day/characters/blockbob/'
                         'blockbob-driving-xp-day-rain.gif')
                    ],
                },
                {
                    'file': ('day/misc/computersays/'
                             'billboard-computer-no-day.gif'),
                    'xy': (386, 51),
                    'probability': 50
                },
                {
                    'file': ('day/misc/computersays/'
                             'billboard-computer-yes-day.gif'),
                    'xy': (386, 51),
                    'else_condition': [
                        'day/misc/computersays/billboard-computer-no-day.gif'
                    ]
                },
                {
                    'file': 'day/misc/3letterLED/3letterLED-UFO-day.gif',
                    'xy': (354, 125),
                    'condition': self._modulo_3_0
                },
                {
                    'file': 'day/misc/3letterLED/3letterLED-LOL-day.gif',
                    'xy': (354, 125),
                    'condition': self._modulo_3_1
                },
                {
                    'file': 'day/misc/3letterLED/3letterLED-404-day.gif',
                    'xy': (354, 125),
                    'condition': self._modulo_3_2
                },
                {
                    'file': 'day/misc/streetlight-yp-day.gif',
                    'xy': (168, 125)
                },
                {
                    'file': 'day/characters/robogroup/robogroup-day.gif',
                    'xy': (554, 168),
                    'probability': 50,
                    'not_condition': self._weather.is_rainy
                },
                {
                    'file': 'day/characters/robogroup/robogroup-day-rain.gif',
                    'xy': (547, 157),
                    'probability': 50,
                    'condition': self._weather.is_rainy
                },
                {
                    'file': 'day/misc/streetlight-xm-day.gif',
                    'xy': (596, 164)
                },
                {
                    'file': 'day/misc/streetlight-yp-day.gif',
                    'xy': (516, 119)
                },
                {
                    'file': ('day/characters/deliverybiker/'
                             'deliverybiker-xm-day.gif'),
                    'xy': (500, 142),
                    'probability': 50,
                    'not_condition': self._weather.is_rainy
                },
                {
                    'file': ('day/characters/deliverybiker/'
                             'deliverybiker-xm-day-rain.gif'),
                    'xy': (492, 135),
                    'probability': 50,
                    'condition': self._weather.is_rainy
                },
                {
                    'file': 'day/environment/fog2-day.gif',
                    'xy': (-640, -384),
                    'condition': self._weather.is_foggy
                },
                {
                    'file': 'day/blocks/block-E/block-E-day.gif',
                    'xy': (12, 51),
                    'not_condition': self._weather.is_rainy
                },
                {
                    'file': 'day/blocks/block-E/block-E-day-rain.gif',
                    'xy': (12, 51),
                    'condition': self._weather.is_rainy
                },
                {
                    'file': 'day/vehicles/boat1/boat1-yp-day.gif',
                    'xy': (6, 238),
                    'probability': 50,
                    'not_condition': self._weather.is_rainy
                },
                {
                    'file': 'day/vehicles/boat1/boat1-yp-day-rain.gif',
                    'xy': (6, 216),
                    'probability': 50,
                    'condition': self._weather.is_rainy
                },
                {
                    'file': 'day/misc/bench-day.gif',
                    'xy': (48, 245)
                },
                {
                    'file': 'day/vehicles/boat2-ym-day.gif',
                    'xy': (12, 261),
                    'probability': 50
                },
                {
                    'file': 'day/characters/ladybiker/ladybiker-day.gif',
                    'xy': (102, 251),
                    'probability': 50,
                    'not_condition': self._weather.is_rainy
                },
                {
                    'file': 'day/characters/ladybiker/ladybiker-day-rain.gif',
                    'xy': (102, 234),
                    'probability': 50,
                    'condition': self._weather.is_rainy
                },
                {
                    'file': 'day/misc/streetlight-ym-day.gif',
                    'xy': (38, 224)
                },
                {
                    'file': 'day/vehicles/van1-yp-day.gif',
                    'xy': (412, 164),
                    'probability': 50
                },
                {
                    'file': 'day/vehicles/van2-milk-yp-day.gif',
                    'xy': (440, 158),
                    'probability': 50
                },
                {
                    'file': 'day/vehicles/van2-yp-day.gif',
                    'xy': (388, 184),
                    'probability': 50
                },
                {
                    'file': 'day/vehicles/car2-xp-day.gif',
                    'xy': (236, 213),
                    'probability': 50
                },
                {
                    'file': 'day/vehicles/car1-yp-day.gif',
                    'xy': (152, 266),
                    'probability': 50
                },
                {
                    'file': 'day/blocks/block-B-day.gif',
                    'xy': (334, 191)
                },
                {
                    'file': 'day/misc/cleat-x-day.gif',
                    'xy': (518, 285)
                },
                {
                    'file': ('day/characters/robogroup/'
                             'robogroup-barge-empty-xm-day.gif'),
                    'xy': (574, 222),
                    'probability': 50,
                    'not_condition': self._weather.is_rainy
                },
                {
                    'file': ('day/characters/robogroup/'
                             'robogroup-barge-empty-xm-day-rain.gif'),
                    'xy': (574, 218),
                    'probability': 50,
                    'condition': self._weather.is_rainy
                },
                {
                    'file': 'day/blocks/bldg-jetty-day.gif',
                    'xy': (516, 230)
                },
                {
                    'file': 'day/misc/streetlight-yp-day.gif',
                    'xy': (528, 255)
                },
                {
                    'file': 'day/environment/fog3-day.gif',
                    'xy': (-640, -384),
                    'condition': self._weather.is_foggy
                },
                {
                    'file': 'day/blocks/park-day.gif',
                    'xy': (379, 252)
                },
                {
                    'file': 'day/characters/dogcouple-day.gif',
                    'xy': (509, 312),
                    'probability': 50
                },
                {
                    'file': 'day/characters/girl/girlwbird-day.gif',
                    'xy': (400, 315),
                    'probability': 50,
                    'not_condition': self._weather.is_rainy
                },
                {
                    'file': 'day/characters/girl/girlwbird-day-rain.gif',
                    'xy': (404, 303),
                    'probability': 50,
                    'condition': self._weather.is_rainy
                },
                {
                    'file': 'day/misc/streetlight-ym-day.gif',
                    'xy': (294, 218)
                },
                {
                    'file': 'day/blocks/block-C-day.gif',
                    'xy': (216, 197)
                },
                {
                    'file': 'day/misc/cleat-y-day.gif',
                    'xy': (400, 346)
                },
                {
                    'file': 'day/characters/vrguys/vrguy-A-day.gif',
                    'xy': (217, 298),
                    'probability': 50,
                    'not_condition': self._weather.is_rainy
                },
                {
                    'file': 'day/characters/vrguys/vrguy-A-day-rain.gif',
                    'xy': (203, 276),
                    'probability': 50,
                    'condition': self._weather.is_rainy
                },
                {
                    'file': 'day/characters/vrguys/vrguy-B-day.gif',
                    'xy': (240, 305),
                    'probability': 50,
                    'not_condition': self._weather.is_rainy
                },
                {
                    'file': 'day/characters/vrguys/vrguy-B-day-rain.gif',
                    'xy': (234, 293),
                    'probability': 50,
                    'condition': self._weather.is_rainy
                },
                {
                    'file': 'day/blocks/bldg-honeybucket-day.gif',
                    'xy': (146, 291)
                },
                {
                    'file': 'day/misc/memorial-minicyclops-day.gif',
                    'xy': (40, 291)
                },
                {
                    'file': 'day/misc/cleat-y-day.gif',
                    'xy': (10, 309)
                },
                {
                    'file': 'day/misc/cleat-y-day.gif',
                    'xy': (26, 317)
                },
                {
                    'file': 'day/misc/memorial-cyclops-day.gif',
                    'xy': (62, 289)
                },
                {
                    'file': 'day/characters/penguin1-day.gif',
                    'xy': (289, 370),
                    'probability': 50
                },
                {
                    'file': 'day/characters/penguin2-day.gif',
                    'xy': (261, 352)
                },
                {
                    'file': 'day/vehicles/yacht2-xm-day.gif',
                    'xy': (544, 302),
                    'probability': 50
                },
                {
                    'file': 'day/vehicles/yacht1-xm-day.gif',
                    'xy': (506, 334),
                    'probability': 50
                },
                {
                    'file': 'day/vehicles/houseboat/houseboat-day.gif',
                    'xy': (163, 326),
                    'probability': 50
                },
                {
                    'file': 'day/misc/streetlight-xp-day.gif',
                    'xy': (216, 322)
                },
                {
                    'file': 'day/environment/fog4-day.gif',
                    'xy': (-640, -384),
                    'condition': self._weather.is_foggy
                },
                {
                    'file': 'day/environment/sun-day.gif',
                    'xy': (19, 17),
                    'not_condition': self._weather.is_foggy
                },
                {
                    'file': 'day/environment/sun-fog-day.gif',
                    'xy': (19, 17),
                    'condition': self._weather.is_foggy
                },
                {
                    'file': 'day/environment/rain1-day.gif',
                    'xy': (-640, -384),
                    'condition': self._weather.is_rainy
                },
                {
                    'file': 'day/environment/snow1-day.gif',
                    'xy': (-640, -384),
                    'condition': self._weather.is_snowy
                },
                {
                    'file': 'day/environment/cloud1-day.gif',
                    'xy': (523, 5),
                    'or_condition': [self._weather.is_partly_cloudy,
                                     self._weather.is_cloudy,
                                     self._weather.is_rainy,
                                     self._weather.is_snowy]
                },
                {
                    'file': 'day/environment/cloud2-day.gif',
                    'xy': (-43, 41),
                    'or_condition': [self._weather.is_partly_cloudy,
                                     self._weather.is_cloudy,
                                     self._weather.is_rainy,
                                     self._weather.is_snowy]
                },
                {
                    'file': 'day/environment/cloud2-day.gif',
                    'xy': (519, 177),
                    'or_condition': [self._weather.is_cloudy,
                                     self._weather.is_rainy,
                                     self._weather.is_snowy]
                },
                {
                    'file': 'day/environment/cloud3-day.gif',
                    'xy': (49, 96),
                    'or_condition': [self._weather.is_cloudy,
                                     self._weather.is_rainy,
                                     self._weather.is_snowy]
                },
                {
                    'file': 'day/environment/cloud4-day.gif',
                    'xy': (195, 156),
                    'or_condition': [self._weather.is_cloudy,
                                     self._weather.is_rainy,
                                     self._weather.is_snowy]
                },
                {
                    'file': 'day/environment/cloud5-day.gif',
                    'xy': (339, 70),
                    'or_condition': [self._weather.is_cloudy,
                                     self._weather.is_rainy,
                                     self._weather.is_snowy]
                },
                {
                    'file': 'day/environment/cloud6-day.gif',
                    'xy': (93, 264),
                    'or_condition': [self._weather.is_cloudy,
                                     self._weather.is_rainy,
                                     self._weather.is_snowy]
                },
                {
                    'file': 'day/environment/cloud7-day.gif',
                    'xy': (472, 247),
                    'or_condition': [self._weather.is_cloudy,
                                     self._weather.is_rainy,
                                     self._weather.is_snowy]
                },
                {
                    'file': 'day/environment/cloud8-day.gif',
                    'xy': (-18, 314),
                    'or_condition': [self._weather.is_cloudy,
                                     self._weather.is_rainy,
                                     self._weather.is_snowy]
                }
            ]
        }, {
            'not_condition': self._sun.is_daylight,
            'layers': [
                {
                    'file': 'night/environment/water-night.gif',
                    'xy': (-640, -384),
                    'or_condition': [self._weather.is_clear,
                                     self._weather.is_partly_cloudy,
                                     self._weather.is_cloudy,
                                     self._weather.is_foggy]
                },
                {
                    'file': 'night/environment/water-flat-night.gif',
                    'xy': (-640, -384),
                    'or_condition': [self._weather.is_rainy,
                                     self._weather.is_snowy]
                },
                {
                    'file': 'night/environment/isle-night.gif',
                    'xy': (-4, 24)
                },
                {
                    'file': 'night/blocks/bldg-facstdo-night.gif',
                    'xy': (262, 9)
                },
                {
                    'file': 'night/misc/lightpole-night.gif',
                    'xy': (130, 5)
                },
                {
                    'file': 'night/blocks/bldg-verylittlegravitas-night.gif',
                    'xy': (188, 18)
                },
                {
                    'file': 'night/blocks/block-D-night.gif',
                    'xy': (74, 59)
                },
                {
                    'file': 'night/vehicles/van2-247-yp-night.gif',
                    'xy': (142, 116),
                    'probability': 50
                },
                {
                    'file': 'night/misc/streetlight-xp-night.gif',
                    'xy': (314, 11)
                },
                {
                    'file': 'night/blocks/block-F-night.gif',
                    'xy': (418, 6)
                },
                {
                    'file': 'night/blocks/bldg-home-night.gif',
                    'xy': (422, 36)
                },
                {
                    'file': 'night/vehicles/boat3-yp-night.gif',
                    'xy': (590, 87),
                    'probability': 80
                },
                {
                    'file': ('night/characters/blockbob/'
                             'blockbob-driving-xp-night.gif'),
                    'xy': (418, 109),
                    'probability': 50,
                    'not_condition': self._weather.is_rainy
                },
                {
                    'file': ('night/characters/blockbob/'
                             'blockbob-driving-xp-night-rain.gif'),
                    'xy': (418, 93),
                    'probability': 50,
                    'condition': self._weather.is_rainy
                },
                {
                    'file': 'night/environment/fog1-night.gif',
                    'xy': (-640, -384),
                    'condition': self._weather.is_foggy
                },
                {
                    'file': 'night/blocks/bldg-robosuper-night.gif',
                    'xy': (540, 116)
                },
                {
                    'file': 'night/characters/dogcouple-night.gif',
                    'xy': (578, 175)
                },
                {
                    'file': 'night/vehicles/car3-yp-night.gif',
                    'xy': (532, 186),
                    'probability': 50
                },
                {
                    'file': 'night/blocks/block-A/block-A-night.gif',
                    'xy': (200, 6),
                    'not_condition': self._weather.is_rainy
                },
                {
                    'file': 'night/blocks/block-A/block-A-night-rain.gif',
                    'xy': (200, 6),
                    'condition': self._weather.is_rainy
                },
                {
                    'file': ('night/characters/blockbob/'
                             'blockbob-sitting-night.gif'),
                    'xy': (276, 78),
                    'else_condition': [
                        ('night/characters/blockbob/'
                         'blockbob-driving-xp-night.gif'),
                        ('night/characters/blockbob/'
                         'blockbob-driving-xp-night-rain.gif')
                    ]
                },
                {
                    'file': ('night/misc/computersays/'
                             'billboard-computer-no-night.gif'),
                    'xy': (386, 51),
                    'probability': 50
                },
                {
                    'file': ('night/misc/computersays/'
                             'billboard-computer-yes-night.gif'),
                    'xy': (386, 51),
                    'else_condition': [
                        ('night/misc/computersays/'
                         'billboard-computer-no-night.gif')
                    ]
                },
                {
                    'file': 'night/misc/3letterLED/3letterLED-UFO-night.gif',
                    'xy': (354, 125),
                    'condition': self._modulo_3_0
                },
                {
                    'file': 'night/misc/3letterLED/3letterLED-LOL-night.gif',
                    'xy': (354, 125),
                    'condition': self._modulo_3_1
                },
                {
                    'file': 'night/misc/3letterLED/3letterLED-404-night.gif',
                    'xy': (354, 125),
                    'condition': self._modulo_3_2
                },
                {
                    'file': 'night/misc/streetlight-yp-night.gif',
                    'xy': (168, 125)
                },
                {
                    'file': 'night/misc/streetlight-xm-night.gif',
                    'xy': (596, 164)
                },
                {
                    'file': 'night/misc/streetlight-yp-night.gif',
                    'xy': (516, 119)
                },
                {
                    'file': 'night/environment/fog2-night.gif',
                    'xy': (-640, -384),
                    'condition': self._weather.is_foggy
                },
                {
                    'file': 'night/blocks/block-E-night.gif',
                    'xy': (12, 51)
                },
                {
                    'file': 'night/vehicles/boat1-yp-night.gif',
                    'xy': (6, 238),
                    'probability': 80,
                    'not_condition': self._weather.is_rainy
                },
                {
                    'file': 'night/vehicles/boat1-yp-night-rain.gif',
                    'xy': (6, 216),
                    'probability': 80,
                    'condition': self._weather.is_rainy
                },
                {
                    'file': 'night/misc/bench-night.gif',
                    'xy': (48, 245)
                },
                {
                    'file': 'night/vehicles/boat2-ym-night.gif',
                    'xy': (12, 261),
                    'probability': 80
                },
                {
                    'file': 'night/misc/streetlight-ym-night.gif',
                    'xy': (38, 224)
                },
                {
                    'file': 'night/vehicles/van1-yp-night.gif',
                    'xy': (400, 164),
                    'probability': 50
                },
                {
                    'file': 'night/vehicles/van2-milk-yp-night.gif',
                    'xy': (440, 158),
                    'probability': 50
                },
                {
                    'file': 'night/vehicles/van2-yp-night.gif',
                    'xy': (374, 184),
                    'probability': 50
                },
                {
                    'file': 'night/vehicles/car2-xp-night.gif',
                    'xy': (236, 213),
                    'probability': 50
                },
                {
                    'file': 'night/vehicles/car1-yp-night.gif',
                    'xy': (138, 266),
                    'probability': 50
                },
                {
                    'file': 'night/blocks/block-B-night.gif',
                    'xy': (334, 191)
                },
                {
                    'file': 'night/misc/cleat-x-night.gif',
                    'xy': (518, 285)
                },
                {
                    'file': ('night/characters/robogroup/'
                             'robogroup-barge-xm-night.gif'),
                    'xy': (574, 222),
                    'probability': 50
                },
                {
                    'file': 'night/blocks/bldg-jetty-night.gif',
                    'xy': (516, 230)
                },
                {
                    'file': 'night/misc/streetlight-yp-night.gif',
                    'xy': (528, 255)
                },
                {
                    'file': 'night/environment/fog3-night.gif',
                    'xy': (-640, -384),
                    'condition': self._weather.is_foggy
                },
                {
                    'file': 'night/blocks/park-night.gif',
                    'xy': (379, 252)
                },
                {
                    'file': 'night/misc/streetlight-ym-night.gif',
                    'xy': (294, 218)
                },
                {
                    'file': 'night/blocks/block-C-night.gif',
                    'xy': (216, 197)
                },
                {
                    'file': 'night/misc/cleat-y-night.gif',
                    'xy': (400, 346)
                },
                {
                    'file': 'night/blocks/bldg-honeybucket-night.gif',
                    'xy': (146, 291)
                },
                {
                    'file': 'night/misc/memorial-minicyclops-night.gif',
                    'xy': (40, 291)
                },
                {
                    'file': 'night/misc/cleat-y-night.gif',
                    'xy': (10, 309)
                },
                {
                    'file': 'night/misc/cleat-y-night.gif',
                    'xy': (26, 317)
                },
                {
                    'file': 'night/misc/memorial-cyclops-night.gif',
                    'xy': (62, 289)
                },
                {
                    'file': 'night/characters/penguin1-night.gif',
                    'xy': (289, 370),
                    'probability': 50
                },
                {
                    'file': 'night/characters/penguin2-night.gif',
                    'xy': (261, 352)
                },
                {
                    'file': 'night/vehicles/yacht2-xm-night.gif',
                    'xy': (544, 302),
                    'probability': 80
                },
                {
                    'file': 'night/vehicles/yacht1-xm-night.gif',
                    'xy': (506, 334),
                    'probability': 80
                },
                {
                    'file': 'night/vehicles/houseboat/houseboat-night.gif',
                    'xy': (163, 326),
                    'probability': 80
                },
                {
                    'file': 'night/misc/streetlight-xp-night.gif',
                    'xy': (216, 322)
                },
                {
                    'file': 'night/environment/fog4-night.gif',
                    'xy': (-640, -384),
                    'condition': self._weather.is_foggy
                },
                {
                    'file': 'night/environment/moon-night.gif',
                    'xy': (19, 17),
                    'not_condition': self._weather.is_foggy
                },
                {
                    'file': 'night/environment/moon-fog-night.gif',
                    'xy': (19, 17),
                    'condition': self._weather.is_foggy
                },
                {
                    'file': 'night/environment/rain1-night.gif',
                    'xy': (-640, -384),
                    'condition': self._weather.is_rainy
                },
                {
                    'file': 'night/environment/snow1-night.gif',
                    'xy': (-640, -384),
                    'condition': self._weather.is_snowy
                },
                {
                    'file': 'night/environment/cloud1-night.gif',
                    'xy': (523, 5),
                    'or_condition': [self._weather.is_partly_cloudy,
                                     self._weather.is_cloudy,
                                     self._weather.is_rainy,
                                     self._weather.is_snowy]
                },
                {
                    'file': 'night/environment/cloud2-night.gif',
                    'xy': (-43, 41),
                    'or_condition': [self._weather.is_partly_cloudy,
                                     self._weather.is_cloudy,
                                     self._weather.is_rainy,
                                     self._weather.is_snowy]
                },
                {
                    'file': 'night/environment/cloud2-night.gif',
                    'xy': (519, 177),
                    'or_condition': [self._weather.is_cloudy,
                                     self._weather.is_rainy,
                                     self._weather.is_snowy]
                },
                {
                    'file': 'night/environment/cloud3-night.gif',
                    'xy': (49, 96),
                    'or_condition': [self._weather.is_cloudy,
                                     self._weather.is_rainy,
                                     self._weather.is_snowy]
                },
                {
                    'file': 'night/environment/cloud4-night.gif',
                    'xy': (195, 156),
                    'or_condition': [self._weather.is_cloudy,
                                     self._weather.is_rainy,
                                     self._weather.is_snowy]
                },
                {
                    'file': 'night/environment/cloud5-night.gif',
                    'xy': (339, 70),
                    'or_condition': [self._weather.is_cloudy,
                                     self._weather.is_rainy,
                                     self._weather.is_snowy]
                },
                {
                    'file': 'night/environment/cloud6-night.gif',
                    'xy': (93, 264),
                    'or_condition': [self._weather.is_cloudy,
                                     self._weather.is_rainy,
                                     self._weather.is_snowy]
                },
                {
                    'file': 'night/environment/cloud7-night.gif',
                    'xy': (472, 247),
                    'or_condition': [self._weather.is_cloudy,
                                     self._weather.is_rainy,
                                     self._weather.is_snowy]
                },
                {
                    'file': 'night/environment/cloud8-night.gif',
                    'xy': (-18, 314),
                    'or_condition': [self._weather.is_cloudy,
                                     self._weather.is_rainy,
                                     self._weather.is_snowy]
                }
            ]
        }]

    def _draw_layers(self, image, layers, user, width, height):
        """Draws a list of layers onto an image."""

        # Keep track of drawn layers.
        drawn_files = []

        # Draw the layers in order.
        for layer in layers:
            try:
                # Simple condition has to be true.
                if not layer['condition'](user):
                    continue
            except KeyError:
                pass

            try:
                # Negated condition has to be false.
                if layer['not_condition'](user):
                    continue
            except KeyError:
                pass

            try:
                # All and-conditions have to be true.
                if not all([c(user) for c in layer['and_condition']]):
                    continue
            except KeyError:
                pass

            try:
                # One or-condition has to be true.
                if not any([c(user) for c in layer['or_condition']]):
                    continue
            except KeyError:
                pass

            try:
                # Else layers have to not have been drawn.
                if not set(layer['else_condition']).isdisjoint(drawn_files):
                    continue
            except KeyError:
                pass

            try:
                # Evaluate a random probability.
                if layer['probability'] <= 100 * random():
                    continue
            except KeyError:
                pass

            # Recursively draw groups of layers.
            try:
                self._draw_layers(image, layer['layers'], user, width, height)
                continue  # Don't try to draw layer groups.
            except KeyError:
                pass

            # Get the coordinates, optionally transforming them first.
            try:
                x, y = layer['xy_transform'](layer['xy_data'])
            except KeyError:
                x, y = layer['xy']

            # Adjust the coordinates to be centered on the display.
            x, y = adjust_xy(x, y, width, height)

            # Draw the layer.
            path = path_join(ASSETS_DIR, layer['file'])
            bitmap = Image.open(path).convert('RGBA')
            image.paste(bitmap, (x, y), bitmap)

            # Remember the drawn file for the else condition.
            drawn_files.append(layer['file'])

    def image(self, user, width, height):
        """Generates the current city image."""

        image = Image.new(mode='RGB', size=(width, height))
        try:
            self._draw_layers(image, self._layers(), user, width, height)
            return image
        except DataError as e:
            raise ContentError(e)
Пример #3
0
class Schedule(ImageContent):
    """A database-backed schedule determining which images to show at request
    time and when to wake up from sleep for the next request.

    The schedule is a list of maps, each containing:
     'name': A human-readable name for this entry.
    'start': A cron expression for the start time of this entry. (The end time
             is the start time of the next closest entry in time.) The cron
             expression syntax additionally supports the keywords 'sunrise' and
             'sunset' instead of hours and minutes, e.g. 'sunrise * * *'.
    'image': The kind of image to show when this entry is active. Valid kinds
             are 'artwork', 'city', 'commute', 'calendar', and 'everyone'.
    """
    def __init__(self, geocoder):
        self.local_time = LocalTime(geocoder)
        self.sun = Sun(geocoder)
        self.artwork = Artwork()
        self.city = City(geocoder)
        self.commute = Commute(geocoder)
        self.calendar = GoogleCalendar(geocoder)
        self.everyone = Everyone(geocoder)

    def _next(self, cron, after, user):
        """Finds the next time matching the cron expression."""

        try:
            cron = self.sun.rewrite_cron(cron, after, user)
        except DataError as e:
            raise ContentError(e)

        try:
            return croniter(cron, after).get_next(datetime)
        except ValueError as e:
            raise ContentError(e)

    def _image(self, kind, user):
        """Creates an image based on the kind."""

        if kind == 'artwork':
            content = self.artwork
        elif kind == 'city':
            content = self.city
        elif kind == 'commute':
            content = self.commute
        elif kind == 'calendar':
            content = self.calendar
        elif kind == 'everyone':
            content = self.everyone
        else:
            error('Unknown image kind: %s' % kind)
            return None

        return content.image(user)

    def image(self, user):
        """Generates the current image based on the schedule."""

        # Find the current schedule entry by parsing the cron expressions.
        try:
            time = self.local_time.now(user)
        except DataError as e:
            raise ContentError(e)
        today = time.replace(hour=0, minute=0, second=0, microsecond=0)
        while True:
            entries = [(self._next(entry['start'], today, user), entry)
                       for entry in user.get('schedule')]
            if not entries:
                raise ContentError('Empty schedule')
            past_entries = list(filter(lambda x: x[0] <= time, entries))

            # Use the most recent past entry.
            if past_entries:
                latest_datetime, latest_entry = max(past_entries,
                                                    key=lambda x: x[0])
                break

            # If there were no past entries, try the previous day.
            today -= timedelta(days=1)

        # Generate the image from the current schedule entry.
        info('Using image from schedule entry: %s (%s, %s)' %
             (latest_entry['name'], latest_entry['start'],
              latest_datetime.strftime('%A %B %d %Y %H:%M:%S %Z')))
        image = self._image(latest_entry['image'], user)

        return image

    def delay(self, user):
        """Calculates the delay in milliseconds to the next schedule entry."""

        # Find the next schedule entry by parsing the cron expressions.
        try:
            time = self.local_time.now(user)
        except DataError as e:
            raise ContentError(e)
        entries = [(self._next(entry['start'], time, user), entry)
                   for entry in user.get('schedule')]
        if not entries:
            raise ContentError('Empty schedule')
        next_datetime, next_entry = min(entries, key=lambda x: x[0])

        # Calculate the delay in milliseconds.
        seconds = (next_datetime - time).total_seconds()
        seconds += DELAY_BUFFER_S
        milliseconds = int(seconds * 1000)
        info('Using time from schedule entry: %s (%s, %s, in %d ms)' %
             (next_entry['name'], next_entry['start'],
              next_datetime.strftime('%A %B %d %Y %H:%M:%S %Z'), milliseconds))

        return milliseconds
Пример #4
0
class Schedule(ImageContent):
    """User schedule processing.

    A database-backed schedule determining which images to show at request
    time and when to wake up from sleep for the next request.

    The schedule is a list of maps, each containing:
     'name': A human-readable name for this entry.
    'start': A cron expression for the start time of this entry. (The end time
             is the start time of the next closest entry in time.) The cron
             expression syntax additionally supports the keywords 'sunrise' and
             'sunset' instead of hours and minutes, e.g. 'sunrise * * *'.
    'image': The kind of image to show when this entry is active. Valid kinds
             are 'artwork', 'city', 'commute', 'calendar', and 'everyone'.
    """
    def __init__(self, geocoder):
        """Schedule constructor.

        Args:
            geocoder (geocoder.Geocoder): Used to localize user specified locations
        """
        self._local_time = LocalTime(geocoder)
        self._sun = Sun(geocoder)
        self._artwork = Artwork()
        self._city = City(geocoder)
        self._commute = Commute(geocoder)
        self._calendar = GoogleCalendar(geocoder)
        self._everyone = Everyone(geocoder)

    def _next(self, cron, after, user):
        """Find the next time matching the cron expression."""
        try:
            cron = self._sun.rewrite_cron(cron, after, user)
        except DataError as e:
            raise ContentError(e)

        try:
            return croniter(cron, after).get_next(datetime)
        except ValueError as e:
            raise ContentError(e)

    def _previous(self, cron, before, user):
        """Find the previous time matching the cron expression."""
        try:
            cron = self._sun.rewrite_cron(cron, before, user)
        except DataError as e:
            raise ContentError(e)

        try:
            return croniter(cron, before).get_prev(datetime)
        except ValueError as e:
            raise ContentError(e)

    def _image(self, kind, user, width, height):
        """Create an image based on the kind."""
        if kind == 'artwork':
            content = self._artwork
        elif kind == 'city':
            content = self._city
        elif kind == 'commute':
            content = self._commute
        elif kind == 'calendar':
            content = self._calendar
        elif kind == 'everyone':
            content = self._everyone
        else:
            error('Unknown image kind: %s' % kind)
            return None

        return content.image(user, width, height)

    def image(self, user, width, height):
        """Generate the current image based on the schedule."""
        # Find the current schedule entry by parsing the cron expressions.
        try:
            time = self._local_time.now(user)
        except DataError as e:
            raise ContentError(e)

        entries = [(self._previous(entry['start'], time, user), entry)
                   for entry in user.get('schedule')]
        if not entries:
            raise ContentError('Empty schedule')

        # Use the most recent past entry.
        latest_datetime, latest_entry = max(entries, key=lambda x: x[0])

        # Generate the image from the current schedule entry.
        info('Using image from schedule entry: %s (%s, %s)' %
             (latest_entry['name'], latest_entry['start'],
              latest_datetime.strftime('%A %B %d %Y %H:%M:%S %Z')))
        image = self._image(latest_entry['image'], user, width, height)

        return image

    def delay(self, user):
        """Calculate the delay in milliseconds to the next schedule entry."""
        # Find the next schedule entry by parsing the cron expressions.
        try:
            time = self._local_time.now(user)
        except DataError as e:
            raise ContentError(e)
        entries = [(self._next(entry['start'], time, user), entry)
                   for entry in user.get('schedule')]
        if not entries:
            raise ContentError('Empty schedule')
        next_datetime, next_entry = min(entries, key=lambda x: x[0])

        # Calculate the delay in milliseconds.
        seconds = (next_datetime - time).total_seconds()
        seconds += DELAY_BUFFER_S
        milliseconds = int(seconds * 1000)
        info('Using time from schedule entry: %s (%s, %s, in %d ms)' %
             (next_entry['name'], next_entry['start'],
              next_datetime.strftime('%A %B %d %Y %H:%M:%S %Z'), milliseconds))

        return milliseconds

    def empty_timeline(self):
        """Generate an empty timeline image."""
        image = Image.new(mode='RGB',
                          size=(TIMELINE_WIDTH, TIMELINE_HEIGHT),
                          color=TIMELINE_BACKGROUND)
        draw = Draw(image)

        # Draw each day of the week.
        num_days = len(day_abbr)
        for day_index in range(num_days):
            x = TIMELINE_DRAW_WIDTH * day_index / num_days

            # Draw a dashed vertical line.
            for y in range(0, TIMELINE_HEIGHT, 2 * TIMELINE_LINE_DASH):
                draw.line([(x, y), (x, y + TIMELINE_LINE_DASH - 1)],
                          fill=TIMELINE_FOREGROUND,
                          width=TIMELINE_LINE_WIDTH)

            # Draw the abbreviated day name.
            name = day_abbr[day_index]
            day_x = x + TIMELINE_DRAW_WIDTH / num_days / 2
            day_y = TIMELINE_HEIGHT - SCREENSTAR_SMALL_REGULAR['height']
            draw_text(name,
                      SCREENSTAR_SMALL_REGULAR,
                      TIMELINE_FOREGROUND,
                      xy=(day_x, day_y),
                      anchor=None,
                      box_color=None,
                      box_padding=0,
                      border_color=None,
                      border_width=0,
                      image=image,
                      draw=draw)

        # Draw another dashed line at the end.
        for y in range(0, TIMELINE_HEIGHT, 2 * TIMELINE_LINE_DASH):
            draw.line([(TIMELINE_DRAW_WIDTH, y),
                       (TIMELINE_DRAW_WIDTH, y + TIMELINE_LINE_DASH - 1)],
                      fill=TIMELINE_FOREGROUND,
                      width=TIMELINE_LINE_WIDTH)

        return image

    def timeline(self, user):
        """Generate a timeline image of the schedule for settings."""
        image = self.empty_timeline()
        draw = Draw(image)

        # Find the user or return the empty timeline.
        try:
            now = self._local_time.now(user)
        except DataError as e:
            return image

        # Start the timeline with the most recent beginning of the week.
        start = now.replace(hour=0, minute=0, second=0)
        start -= timedelta(days=start.weekday())
        stop = start + timedelta(weeks=1)
        start_timestamp = datetime.timestamp(start)
        stop_timestamp = datetime.timestamp(stop)
        timestamp_span = stop_timestamp - start_timestamp

        # Draw a dashed line in highlight color at the current time.
        now_timestamp = datetime.timestamp(now)
        now_x = TIMELINE_DRAW_WIDTH * (now_timestamp -
                                       start_timestamp) / timestamp_span
        for y in range(0, TIMELINE_HEIGHT, 2 * TIMELINE_LINE_DASH):
            draw.line([(now_x, y), (now_x, y + TIMELINE_LINE_DASH - 1)],
                      fill=TIMELINE_HIGHLIGHT,
                      width=TIMELINE_LINE_WIDTH)

        # Generate the schedule throughout the week.
        entries = user.get('schedule')
        if not entries:
            # Empty timeline.
            return image
        for i in range(len(entries)):
            entries[i]['index'] = i
        time = start
        while time < stop:
            # Find the next entry.
            next_entries = [(self._next(entry['start'], time,
                                        user), entry['index'], entry)
                            for entry in entries]
            next_datetime, next_index, next_entry = min(next_entries,
                                                        key=lambda x: x[0])

            # Draw the entry's index and a vertical line, with a tilde to mark
            # the variable sunrise and sunset times.
            timestamp = datetime.timestamp(next_datetime)
            x = TIMELINE_DRAW_WIDTH * (timestamp -
                                       start_timestamp) / timestamp_span
            y = TIMELINE_HEIGHT / 2
            text = str(next_index + 1)
            next_entry_start = next_entry['start']
            if 'sunrise' in next_entry_start or 'sunset' in next_entry_start:
                text = '~' + text
            box = draw_text(text,
                            SCREENSTAR_SMALL_REGULAR,
                            TIMELINE_FOREGROUND,
                            xy=(x, y),
                            anchor=None,
                            box_color=None,
                            box_padding=4,
                            border_color=None,
                            border_width=0,
                            image=image,
                            draw=draw)
            draw.line([(x, 0), (x, box[1])], fill=TIMELINE_FOREGROUND, width=1)

            # Jump to the next entry.
            time = next_datetime

        return image
Пример #5
0
class Sun(object):
    """A wrapper around a calculator for sunrise and sunset times."""
    def __init__(self, geocoder):
        self.astral = Astral(geocoder=GeocoderWrapper, wrapped=geocoder)
        self.local_time = LocalTime(geocoder)

    def rewrite_cron(self, cron, after, user):
        """Replaces references to sunrise and sunset in a cron expression."""

        # Skip if there is nothing to rewrite.
        if 'sunrise' not in cron and 'sunset' not in cron:
            return cron

        # Determine the first two days of the cron expression after the
        # reference, which covers all candidate sunrises and sunsets.
        yesterday = after - timedelta(days=1)
        midnight_cron = cron.replace('sunrise', '0 0').replace('sunset', '0 0')
        try:
            first_day = croniter(midnight_cron, yesterday).get_next(datetime)
            second_day = croniter(midnight_cron, first_day).get_next(datetime)
        except ValueError as e:
            raise DataError(e)

        zone = self.local_time.zone(user)
        try:
            home = self.astral[user.get('home')]
        except (AstralError, KeyError) as e:
            raise DataError(e)

        # Calculate the closest future sunrise time and replace the term in the
        # cron expression with minutes and hours.
        if 'sunrise' in cron:
            sunrises = map(lambda x: home.sunrise(x).astimezone(zone),
                           [first_day, second_day])
            next_sunrise = min(filter(lambda x: x >= after, sunrises))
            sunrise_cron = cron.replace(
                'sunrise', '%d %d' % (next_sunrise.minute, next_sunrise.hour))
            info('Rewrote cron: (%s) -> (%s), after %s' %
                 (cron, sunrise_cron,
                  after.strftime('%A %B %d %Y %H:%M:%S %Z')))
            return sunrise_cron

        # Calculate the closest future sunset time and replace the term in the
        # cron expression with minutes and hours.
        if 'sunset' in cron:
            sunsets = map(lambda x: home.sunset(x).astimezone(zone),
                          [first_day, second_day])
            next_sunset = min(filter(lambda x: x >= after, sunsets))
            sunset_cron = cron.replace(
                'sunset', '%d %d' % (next_sunset.minute, next_sunset.hour))
            info(
                'Rewrote cron: (%s) -> (%s), after %s' %
                (cron, sunset_cron, after.strftime('%A %B %d %Y %H:%M:%S %Z')))
            return sunset_cron

    def is_daylight(self, user):
        """Calculates whether the sun is currently up."""

        # Find the sunrise and sunset times for today.
        time = self.local_time.now(user)
        zone = self.local_time.zone(user)
        try:
            home = self.astral[user.get('home')]
        except (AstralError, KeyError) as e:
            raise DataError(e)
        sunrise = home.sunrise(time).astimezone(zone)
        sunset = home.sunset(time).astimezone(zone)

        is_daylight = time > sunrise and time < sunset

        info('Daylight: %s (%s)' %
             (is_daylight, time.strftime('%A %B %d %Y %H:%M:%S %Z')))

        return is_daylight
Пример #6
0
class Sun(object):
    """A wrapper around a calculator for sunrise and sunset times."""

    def __init__(self, geocoder):
        self._astral = Astral(geocoder=GeocoderWrapper, wrapped=geocoder)
        self._local_time = LocalTime(geocoder)

    def rewrite_cron(self, cron, reference, user, forward=True):
        """Replaces references to sunrise and sunset in a cron expression."""

        # Skip if there is nothing to rewrite.
        if 'sunrise' not in cron and 'sunset' not in cron:
            return cron

        # Determine the two days surrounding the cron expression for the
        # reference time, which covers all candidate sunrises and sunsets.
        yesterday = reference - timedelta(days=2)
        midnight_cron = cron.replace('sunrise', '0 0').replace('sunset', '0 0')
        try:
            prev_day = croniter(midnight_cron, yesterday).get_next(datetime)
            current_day = croniter(midnight_cron, prev_day).get_next(datetime)
            next_day = croniter(midnight_cron, current_day).get_next(datetime)

        except ValueError as e:
            raise DataError(e)

        zone = self._local_time.zone(user)
        try:
            home = self._astral[user.get('home')]
        except (AstralError, KeyError) as e:
            raise DataError(e)

        # Set the candidate days and filter based on direction
        candidate_days = []
        direction_filter = None
        sorter = None

        def forward_filter(x):
            return x >= reference

        def backward_filter(x):
            return x <= reference

        if forward:
            candidate_days.extend([current_day, next_day])
            direction_filter = forward_filter
            sorter = min
        else:
            candidate_days.extend([prev_day, current_day])
            direction_filter = backward_filter
            sorter = max

        # Calculate the closest sunrise time and replace the term in the
        # cron expression with minutes and hours.
        if 'sunrise' in cron:
            sunrises = map(
                    lambda x: home.sunrise(x).astimezone(zone),
                    candidate_days)
            next_sunrise = sorter(filter(direction_filter, sunrises))
            sunrise_cron = cron.replace('sunrise', '%d %d' % (
                next_sunrise.minute, next_sunrise.hour))
            info('Rewrote cron: (%s) -> (%s), reference %s' % (
                cron,
                sunrise_cron,
                reference.strftime('%A %B %d %Y %H:%M:%S %Z')))
            return sunrise_cron

        # Calculate the closest future sunset time and replace the term in the
        # cron expression with minutes and hours.
        if 'sunset' in cron:
            sunsets = map(
                    lambda x: home.sunset(x).astimezone(zone),
                    candidate_days)
            next_sunset = sorter(filter(direction_filter, sunsets))
            sunset_cron = cron.replace('sunset', '%d %d' % (next_sunset.minute,
                                                            next_sunset.hour))
            info('Rewrote cron: (%s) -> (%s), reference %s' % (
                cron,
                sunset_cron,
                reference.strftime('%A %B %d %Y %H:%M:%S %Z')))
            return sunset_cron

    def is_daylight(self, user):
        """Calculates whether the sun is currently up."""

        # Find the sunrise and sunset times for today.
        time = self._local_time.now(user)
        zone = self._local_time.zone(user)
        try:
            home = self._astral[user.get('home')]
        except (AstralError, KeyError) as e:
            raise DataError(e)
        sunrise = home.sunrise(time).astimezone(zone)
        sunset = home.sunset(time).astimezone(zone)

        is_daylight = time > sunrise and time < sunset

        info('Daylight: %s (%s)' % (is_daylight,
                                    time.strftime('%A %B %d %Y %H:%M:%S %Z')))

        return is_daylight