Skip to content

📘 Inkycal API Reference

Welcome to the full API reference for Inkycal.

This page contains:

  • A complete list of all available modules
  • API documentation for the Canvas and Display classes
  • Utilities included in inkycal.utils
  • Auto-generated API docs (if mkdocstrings is enabled)

🧩 Architecture Summary

Inkycal is composed of:

Component Location Description
Modules inkycal/modules/ Pluggable units (Calendar, Weather, Stocks, etc.)
Display Interface inkycal/display/ Hardware drivers for supported ePaper displays
Canvas Engine inkycal/utils/canvas.py Text / icon / drawing engine used by all modules
Utilities inkycal/utils/ Helpers (timezone, borders, networking, line charts)

Most modules extend:

inkycal.modules.template.InkycalModule

📦 Built-in Modules

Inkycal ships with the following modules, available via the Web-UI and automatically registered via:

inkycal.modules.__init__.py

Module Index

Name Class Import Path
Agenda Agenda inkycal.modules.inkycal_agenda.Agenda
Calendar Calendar inkycal.modules.inkycal_calendar.Calendar
Feeds Feeds inkycal.modules.inkycal_feeds.Feeds
Image Inkyimage inkycal.modules.inky_image.Inkyimage
Jokes Jokes inkycal.modules.inkycal_jokes.Jokes
Server Status Inkyserver inkycal.modules.inkycal_server.Inkyserver
Slideshow Slideshow inkycal.modules.inkycal_slideshow.Slideshow
Stocks Stocks inkycal.modules.inkycal_stocks.Stocks
Text File Renderer TextToDisplay inkycal.modules.inkycal_textfile_to_display.TextToDisplay
Tindie Stats Tindie inkycal.modules.inkycal_tindie.Tindie
Todoist Todoist inkycal.modules.inkycal_todoist.Todoist
Weather Weather inkycal.modules.inkycal_weather.Weather
Webshot Webshot inkycal.modules.inkycal_webshot.Webshot
XKCD Xkcd inkycal.modules.inkycal_xkcd.Xkcd

📚 Module Reference (Auto-Generated)

Template Module

::: inkycal.modules.template

Calendar

Inkycal Calendar Module Copyright by aceinnolab

Calendar

Bases: InkycalModule

Calendar class Create monthly calendar and show events from given iCalendars

Source code in inkycal/modules/inkycal_calendar.py
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
class Calendar(InkycalModule):
    """Calendar class
    Create monthly calendar and show events from given iCalendars
    """

    name = "Calendar - Show monthly calendar with events from iCalendars"

    optional = {
        "week_starts_on": {
            "label": "When does your week start? (default=Monday)",
            "options": ["Monday", "Sunday"],
            "default": "Monday",
        },
        "show_events": {
            "label": "Show parsed events? (default = True)",
            "options": [True, False],
            "default": True,
        },
        "ical_urls": {
            "label": "iCalendar URL/s, separate multiple ones with a comma",
        },
        "ical_files": {
            "label": "iCalendar filepaths, separated with a comma",
        },
        "date_format": {
            "label": "Use an arrow-supported token for custom date formatting "
                     + "see https://arrow.readthedocs.io/en/stable/#supported-tokens, e.g. D MMM",
            "default": "D MMM",
        },
        "time_format": {
            "label": "Use an arrow-supported token for custom time formatting "
                     + "see https://arrow.readthedocs.io/en/stable/#supported-tokens, e.g. HH:mm",
            "default": "HH:mm",
        },
    }

    def __init__(self, config):
        """Initialize inkycal_calendar module"""

        super().__init__(config)
        config = config['config']

        self.ical = None
        self.month_events = None
        self._upcoming_events = None
        self._days_with_events = None

        # optional parameters
        self.week_start = config['week_starts_on']
        self.show_events = config['show_events']
        self.date_format = config["date_format"]
        self.time_format = config['time_format']
        self.language = config['language']

        if config['ical_urls'] and isinstance(config['ical_urls'], str):
            self.ical_urls = config['ical_urls'].split(',')
        else:
            self.ical_urls = config['ical_urls']

        if config['ical_files'] and isinstance(config['ical_files'], str):
            self.ical_files = config['ical_files'].split(',')
        else:
            self.ical_files = config['ical_files']

        # additional configuration
        self.timezone = get_system_tz()
        self.num_font = FONTS.noto_sans_semicondensed

        # give an OK message
        logger.debug(f'{__name__} loaded')

    @staticmethod
    def flatten(values):
        """Flatten the values."""
        return [x for y in values for x in y]

    def generate_image(self):
        """Generate image for this module"""

        # Define new image size with respect to padding
        im_width = int(self.width - (2 * self.padding_left))
        im_height = int(self.height - (2 * self.padding_top))
        im_size = im_width, im_height
        events_height = 0

        logger.debug(f'Image size: {im_size}')

        canvas = Canvas(im_size, font=self.font, font_size=self.fontsize)

        # Allocate space for month-names, weekdays etc.
        month_name_height = int(im_height * 0.10) # 10% of the available height
        weekdays_height = int(canvas.get_line_height() * 1.25) # slightly more height for some padding
        logger.debug(f"month_name_height: {month_name_height}")
        logger.debug(f"weekdays_height: {weekdays_height}")

        if self.show_events:
            logger.debug("Allocating space for events")
            calendar_height = int(im_height * 0.6)
            events_height = (
                    im_height - month_name_height - weekdays_height - calendar_height
            )
            logger.debug(f'calendar-section size: {im_width} x {calendar_height} px')
            logger.debug(f'events-section size: {im_width} x {events_height} px')
        else:
            logger.debug("Not allocating space for events")
            calendar_height = im_height - month_name_height - weekdays_height
            logger.debug(f'calendar-section size: {im_width} x {calendar_height} px')

        # Create a 7x6 grid and calculate icon sizes
        calendar_rows, calendar_cols = 6, 7
        icon_width = im_width // calendar_cols
        icon_height = calendar_height // calendar_rows
        logger.debug(f"icon_size: {icon_width}x{icon_height}px")

        # Calculate spacings for calendar area
        x_spacing_calendar = int((im_width % calendar_cols) / 2)
        y_spacing_calendar = int((im_height % calendar_rows) / 2)

        logger.debug(f"x_spacing_calendar: {x_spacing_calendar}")
        logger.debug(f"y_spacing_calendar :{y_spacing_calendar}")

        # Calculate positions for days of month
        grid_start_y = month_name_height + weekdays_height + y_spacing_calendar
        grid_start_x = x_spacing_calendar

        grid_coordinates = [
            (grid_start_x + icon_width * x, grid_start_y + icon_height * y)
            for y in range(calendar_rows)
            for x in range(calendar_cols)
        ]

        weekday_pos = [
            (grid_start_x + icon_width * _, month_name_height)
            for _ in range(calendar_cols)
        ]

        now = arrow.now(tz=self.timezone)

        # Set week-start of calendar to specified week-start
        if self.week_start == "Monday":
            cal.setfirstweekday(cal.MONDAY)
            week_start = now.shift(days=-now.weekday())
        else:
            cal.setfirstweekday(cal.SUNDAY)
            week_start = now.shift(days=-now.isoweekday())

        # Write the name of current month
        canvas.write(
            xy=(0,0),
            box_size=(im_width, month_name_height),
            text= str(now.format('MMMM', locale=self.language)),
            autofit=True,
        )

        # Set up week-names in local language and add to main section
        weekday_names = [
            week_start.shift(days=+_).format('ddd', locale=self.language)
            for _ in range(7)
        ]
        logger.debug(f'weekday names: {weekday_names}')

        for index, weekday in enumerate(weekday_pos):
            canvas.write(
                xy=weekday,
                box_size=(icon_width, weekdays_height),
                text=weekday_names[index],
                autofit=True,
                fill_height=0.9,
            )

        # Create a calendar template and flatten (remove nesting)
        calendar_flat = self.flatten(cal.monthcalendar(now.year, now.month))
        # logger.debug(f" calendar_flat: {calendar_flat}")

        # Map days of month to co-ordinates of grid -> 3: (row2_x,col3_y)
        grid = {}
        for i in calendar_flat:
            if i != 0:
                grid[i] = grid_coordinates[calendar_flat.index(i)]
        # logger.debug(f"grid:{grid}")

        # remove zeros from calendar since they are not required
        calendar_flat = [num for num in calendar_flat if num != 0]

        # ensure all numbers have the same size
        fontsize_numbers = int(min(icon_width, icon_height) * 0.5)

        canvas.set_font(self.font, fontsize_numbers)
        # Add the numbers on the correct positions
        for number in calendar_flat:
            if number != int(now.day):
                canvas.write(
                    xy=grid[number],
                    box_size=(icon_width, icon_height),
                    text= str(number)
                )

        canvas.set_font(self.font, self.fontsize)

        # special handling of current day
        day_str = str(now.day)

        # Icon canvas
        icon = Image.new("RGBA", (icon_width, icon_height), (0, 0, 0, 0))
        draw = ImageDraw.Draw(icon)

        # --- Larger circle (properly centered) ---
        cx = icon_width // 2
        cy = icon_height // 2
        radius = int(icon_width * 0.40)

        draw.ellipse(
            (cx - radius, cy - radius, cx + radius, cy + radius),
            fill="black"
        )

        # --- Load number font ---
        font = ImageFont.truetype(self.num_font.value, fontsize_numbers)

        # --- Perfect vertical + horizontal centering using anchor="mm" ---
        draw.text(
            (cx, cy),
            day_str,
            fill="white",
            font=font,
            anchor="mm"  # ← the magic incantation
        )

        # --- Paste onto main canvas ---
        cell_x, cell_y = grid[int(now.day)]

        # center the icon inside its grid cell
        paste_x = cell_x + (icon_width - icon.width) // 2
        paste_y = cell_y + (icon_height - icon.height) // 2

        canvas.image_black.paste(icon, (paste_x, paste_y), icon)
        canvas.image_colour.paste(icon, (paste_x, paste_y), icon)




        # If events should be loaded and shown...
        if self.show_events:

            # If this month requires 5 instead of 6 rows, increase event section height
            if len(cal.monthcalendar(now.year, now.month)) == 5:
                events_height += icon_height

            # If this month requires 4 instead of 6 rows, increase event section height
            elif len(cal.monthcalendar(now.year, now.month)) == 4:
                events_height += icon_height * 2

            # import the ical-parser
            # pylint: disable=import-outside-toplevel
            from inkycal.utils.ical_parser import iCalendar

            # find out how many lines can fit at max in the event section
            line_spacing = 2
            line_height = canvas.get_line_height() + line_spacing
            max_event_lines = events_height // (line_height + line_spacing)

            # generate list of coordinates for each line
            events_offset = im_height - events_height
            event_lines = [
                (0, events_offset + int(events_height / max_event_lines * _))
                for _ in range(max_event_lines)
            ]

            # logger.debug(f"event_lines {event_lines}")

            # timeline for filtering events within this month
            month_start = arrow.get(now.floor('month'))
            month_end = arrow.get(now.ceil('month'))

            # fetch events from given iCalendars
            self.ical = iCalendar()
            parser = self.ical

            if self.ical_urls:
                parser.load_url(self.ical_urls)
            if self.ical_files:
                parser.load_from_file(self.ical_files)

            # Filter events for full month (even past ones) for drawing event icons
            month_events = parser.get_events(month_start, month_end, self.timezone)
            parser.sort()
            self.month_events = month_events

            # Initialize days_with_events as an empty list
            days_with_events = []

            # Handle multi-day events by adding all days between start and end
            for event in month_events:

                # Convert start and end dates to arrow objects with timezone
                start = arrow.get(event['begin'].date(), tzinfo=self.timezone)
                end = arrow.get(event['end'].date(), tzinfo=self.timezone)

                # Use arrow's range function for generating dates
                for day in arrow.Arrow.range('day', start, end):
                    day_num = int(day.format('D'))  # get day number using arrow's format method
                    if day_num not in days_with_events:
                        days_with_events.append(day_num)

            # remove duplicates (more than one event in a single day)
            days_with_events = sorted(set(days_with_events))
            self._days_with_events = days_with_events

            # Draw a border with specified parameters around days with events
            for days in days_with_events:
                if days in grid:
                    draw_border(
                        canvas.image_colour,
                        grid[days],
                        (icon_width, icon_height),
                        radius=6
                    )

            # Filter upcoming events until 4 weeks in the future
            parser.clear_events()
            upcoming_events = parser.get_events(now, now.shift(weeks=4), self.timezone)
            self._upcoming_events = upcoming_events

            # delete events which won't be able to fit (more events than lines)
            upcoming_events = upcoming_events[:max_event_lines]

            # Check if any events were found in the given timerange
            if upcoming_events:

                # Find out how much space (width) the date format requires
                lang = self.language

                date_width = int(max((
                    canvas.get_text_width(events['begin'].format(self.date_format, locale=lang))
                    for events in upcoming_events)) * 1.1
                                 )

                time_width = int(max((
                    canvas.get_text_width(events['begin'].format(self.time_format, locale=lang))
                    for events in upcoming_events)) * 1.1
                                 )

                line_height = canvas.get_line_height() + line_spacing

                event_width_s = im_width - date_width - time_width
                event_width_l = im_width - date_width

                # Display upcoming events below calendar TODO: not used?
                # tomorrow = now.shift(days=1).floor('day')
                # in_two_days = now.shift(days=2).floor('day')

                cursor = 0
                for event in upcoming_events:
                    if cursor < len(event_lines):
                        event_duration = (event['end'] - event['begin']).days
                        if event_duration > 1:
                            # Format the duration using Arrow's localization
                            days_translation = arrow.get().shift(days=event_duration).humanize(only_distance=True,
                                                                                               locale=lang)
                            the_name = f"{event['title']} ({days_translation})"
                        else:
                            the_name = event['title']
                        the_date = event['begin'].format(self.date_format, locale=lang)
                        the_time = event['begin'].format(self.time_format, locale=lang)
                        # logger.debug(f"name:{the_name}   date:{the_date} time:{the_time}")

                        if now < event['end']:
                            canvas.write(
                                xy=event_lines[cursor],
                                box_size=(date_width, line_height),
                                text=the_date,
                                alignment='left',
                            )

                            # Check if event is all day
                            if parser.all_day(event):
                                canvas.write(
                                    xy=(date_width, event_lines[cursor][1]),
                                    box_size= (event_width_l, line_height),
                                    text=the_name,
                                    alignment='left',
                                )
                            else:
                                canvas.write(
                                    xy=(date_width, event_lines[cursor][1]),
                                    box_size=(time_width, line_height),
                                    text=the_time,
                                    alignment='left',
                                )

                                canvas.write(
                                    xy=(date_width + time_width, event_lines[cursor][1]),
                                    box_size=(event_width_s, line_height),
                                    text=the_name,
                                    alignment='left',
                                )
                            cursor += 1
            else:
                symbol = '- '
                length = canvas.get_text_width(symbol)
                multiplier = int(im_width // length)
                symbol = symbol * multiplier

                canvas.write(
                    xy=event_lines[0],
                    box_size=(im_width, line_height),
                    text=symbol,
                    alignment='left',
                    )

        # return the images ready for the display
        return canvas.image_black, canvas.image_colour

__init__(config)

Initialize inkycal_calendar module

Source code in inkycal/modules/inkycal_calendar.py
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
def __init__(self, config):
    """Initialize inkycal_calendar module"""

    super().__init__(config)
    config = config['config']

    self.ical = None
    self.month_events = None
    self._upcoming_events = None
    self._days_with_events = None

    # optional parameters
    self.week_start = config['week_starts_on']
    self.show_events = config['show_events']
    self.date_format = config["date_format"]
    self.time_format = config['time_format']
    self.language = config['language']

    if config['ical_urls'] and isinstance(config['ical_urls'], str):
        self.ical_urls = config['ical_urls'].split(',')
    else:
        self.ical_urls = config['ical_urls']

    if config['ical_files'] and isinstance(config['ical_files'], str):
        self.ical_files = config['ical_files'].split(',')
    else:
        self.ical_files = config['ical_files']

    # additional configuration
    self.timezone = get_system_tz()
    self.num_font = FONTS.noto_sans_semicondensed

    # give an OK message
    logger.debug(f'{__name__} loaded')

flatten(values) staticmethod

Flatten the values.

Source code in inkycal/modules/inkycal_calendar.py
91
92
93
94
@staticmethod
def flatten(values):
    """Flatten the values."""
    return [x for y in values for x in y]

generate_image()

Generate image for this module

Source code in inkycal/modules/inkycal_calendar.py
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
def generate_image(self):
    """Generate image for this module"""

    # Define new image size with respect to padding
    im_width = int(self.width - (2 * self.padding_left))
    im_height = int(self.height - (2 * self.padding_top))
    im_size = im_width, im_height
    events_height = 0

    logger.debug(f'Image size: {im_size}')

    canvas = Canvas(im_size, font=self.font, font_size=self.fontsize)

    # Allocate space for month-names, weekdays etc.
    month_name_height = int(im_height * 0.10) # 10% of the available height
    weekdays_height = int(canvas.get_line_height() * 1.25) # slightly more height for some padding
    logger.debug(f"month_name_height: {month_name_height}")
    logger.debug(f"weekdays_height: {weekdays_height}")

    if self.show_events:
        logger.debug("Allocating space for events")
        calendar_height = int(im_height * 0.6)
        events_height = (
                im_height - month_name_height - weekdays_height - calendar_height
        )
        logger.debug(f'calendar-section size: {im_width} x {calendar_height} px')
        logger.debug(f'events-section size: {im_width} x {events_height} px')
    else:
        logger.debug("Not allocating space for events")
        calendar_height = im_height - month_name_height - weekdays_height
        logger.debug(f'calendar-section size: {im_width} x {calendar_height} px')

    # Create a 7x6 grid and calculate icon sizes
    calendar_rows, calendar_cols = 6, 7
    icon_width = im_width // calendar_cols
    icon_height = calendar_height // calendar_rows
    logger.debug(f"icon_size: {icon_width}x{icon_height}px")

    # Calculate spacings for calendar area
    x_spacing_calendar = int((im_width % calendar_cols) / 2)
    y_spacing_calendar = int((im_height % calendar_rows) / 2)

    logger.debug(f"x_spacing_calendar: {x_spacing_calendar}")
    logger.debug(f"y_spacing_calendar :{y_spacing_calendar}")

    # Calculate positions for days of month
    grid_start_y = month_name_height + weekdays_height + y_spacing_calendar
    grid_start_x = x_spacing_calendar

    grid_coordinates = [
        (grid_start_x + icon_width * x, grid_start_y + icon_height * y)
        for y in range(calendar_rows)
        for x in range(calendar_cols)
    ]

    weekday_pos = [
        (grid_start_x + icon_width * _, month_name_height)
        for _ in range(calendar_cols)
    ]

    now = arrow.now(tz=self.timezone)

    # Set week-start of calendar to specified week-start
    if self.week_start == "Monday":
        cal.setfirstweekday(cal.MONDAY)
        week_start = now.shift(days=-now.weekday())
    else:
        cal.setfirstweekday(cal.SUNDAY)
        week_start = now.shift(days=-now.isoweekday())

    # Write the name of current month
    canvas.write(
        xy=(0,0),
        box_size=(im_width, month_name_height),
        text= str(now.format('MMMM', locale=self.language)),
        autofit=True,
    )

    # Set up week-names in local language and add to main section
    weekday_names = [
        week_start.shift(days=+_).format('ddd', locale=self.language)
        for _ in range(7)
    ]
    logger.debug(f'weekday names: {weekday_names}')

    for index, weekday in enumerate(weekday_pos):
        canvas.write(
            xy=weekday,
            box_size=(icon_width, weekdays_height),
            text=weekday_names[index],
            autofit=True,
            fill_height=0.9,
        )

    # Create a calendar template and flatten (remove nesting)
    calendar_flat = self.flatten(cal.monthcalendar(now.year, now.month))
    # logger.debug(f" calendar_flat: {calendar_flat}")

    # Map days of month to co-ordinates of grid -> 3: (row2_x,col3_y)
    grid = {}
    for i in calendar_flat:
        if i != 0:
            grid[i] = grid_coordinates[calendar_flat.index(i)]
    # logger.debug(f"grid:{grid}")

    # remove zeros from calendar since they are not required
    calendar_flat = [num for num in calendar_flat if num != 0]

    # ensure all numbers have the same size
    fontsize_numbers = int(min(icon_width, icon_height) * 0.5)

    canvas.set_font(self.font, fontsize_numbers)
    # Add the numbers on the correct positions
    for number in calendar_flat:
        if number != int(now.day):
            canvas.write(
                xy=grid[number],
                box_size=(icon_width, icon_height),
                text= str(number)
            )

    canvas.set_font(self.font, self.fontsize)

    # special handling of current day
    day_str = str(now.day)

    # Icon canvas
    icon = Image.new("RGBA", (icon_width, icon_height), (0, 0, 0, 0))
    draw = ImageDraw.Draw(icon)

    # --- Larger circle (properly centered) ---
    cx = icon_width // 2
    cy = icon_height // 2
    radius = int(icon_width * 0.40)

    draw.ellipse(
        (cx - radius, cy - radius, cx + radius, cy + radius),
        fill="black"
    )

    # --- Load number font ---
    font = ImageFont.truetype(self.num_font.value, fontsize_numbers)

    # --- Perfect vertical + horizontal centering using anchor="mm" ---
    draw.text(
        (cx, cy),
        day_str,
        fill="white",
        font=font,
        anchor="mm"  # ← the magic incantation
    )

    # --- Paste onto main canvas ---
    cell_x, cell_y = grid[int(now.day)]

    # center the icon inside its grid cell
    paste_x = cell_x + (icon_width - icon.width) // 2
    paste_y = cell_y + (icon_height - icon.height) // 2

    canvas.image_black.paste(icon, (paste_x, paste_y), icon)
    canvas.image_colour.paste(icon, (paste_x, paste_y), icon)




    # If events should be loaded and shown...
    if self.show_events:

        # If this month requires 5 instead of 6 rows, increase event section height
        if len(cal.monthcalendar(now.year, now.month)) == 5:
            events_height += icon_height

        # If this month requires 4 instead of 6 rows, increase event section height
        elif len(cal.monthcalendar(now.year, now.month)) == 4:
            events_height += icon_height * 2

        # import the ical-parser
        # pylint: disable=import-outside-toplevel
        from inkycal.utils.ical_parser import iCalendar

        # find out how many lines can fit at max in the event section
        line_spacing = 2
        line_height = canvas.get_line_height() + line_spacing
        max_event_lines = events_height // (line_height + line_spacing)

        # generate list of coordinates for each line
        events_offset = im_height - events_height
        event_lines = [
            (0, events_offset + int(events_height / max_event_lines * _))
            for _ in range(max_event_lines)
        ]

        # logger.debug(f"event_lines {event_lines}")

        # timeline for filtering events within this month
        month_start = arrow.get(now.floor('month'))
        month_end = arrow.get(now.ceil('month'))

        # fetch events from given iCalendars
        self.ical = iCalendar()
        parser = self.ical

        if self.ical_urls:
            parser.load_url(self.ical_urls)
        if self.ical_files:
            parser.load_from_file(self.ical_files)

        # Filter events for full month (even past ones) for drawing event icons
        month_events = parser.get_events(month_start, month_end, self.timezone)
        parser.sort()
        self.month_events = month_events

        # Initialize days_with_events as an empty list
        days_with_events = []

        # Handle multi-day events by adding all days between start and end
        for event in month_events:

            # Convert start and end dates to arrow objects with timezone
            start = arrow.get(event['begin'].date(), tzinfo=self.timezone)
            end = arrow.get(event['end'].date(), tzinfo=self.timezone)

            # Use arrow's range function for generating dates
            for day in arrow.Arrow.range('day', start, end):
                day_num = int(day.format('D'))  # get day number using arrow's format method
                if day_num not in days_with_events:
                    days_with_events.append(day_num)

        # remove duplicates (more than one event in a single day)
        days_with_events = sorted(set(days_with_events))
        self._days_with_events = days_with_events

        # Draw a border with specified parameters around days with events
        for days in days_with_events:
            if days in grid:
                draw_border(
                    canvas.image_colour,
                    grid[days],
                    (icon_width, icon_height),
                    radius=6
                )

        # Filter upcoming events until 4 weeks in the future
        parser.clear_events()
        upcoming_events = parser.get_events(now, now.shift(weeks=4), self.timezone)
        self._upcoming_events = upcoming_events

        # delete events which won't be able to fit (more events than lines)
        upcoming_events = upcoming_events[:max_event_lines]

        # Check if any events were found in the given timerange
        if upcoming_events:

            # Find out how much space (width) the date format requires
            lang = self.language

            date_width = int(max((
                canvas.get_text_width(events['begin'].format(self.date_format, locale=lang))
                for events in upcoming_events)) * 1.1
                             )

            time_width = int(max((
                canvas.get_text_width(events['begin'].format(self.time_format, locale=lang))
                for events in upcoming_events)) * 1.1
                             )

            line_height = canvas.get_line_height() + line_spacing

            event_width_s = im_width - date_width - time_width
            event_width_l = im_width - date_width

            # Display upcoming events below calendar TODO: not used?
            # tomorrow = now.shift(days=1).floor('day')
            # in_two_days = now.shift(days=2).floor('day')

            cursor = 0
            for event in upcoming_events:
                if cursor < len(event_lines):
                    event_duration = (event['end'] - event['begin']).days
                    if event_duration > 1:
                        # Format the duration using Arrow's localization
                        days_translation = arrow.get().shift(days=event_duration).humanize(only_distance=True,
                                                                                           locale=lang)
                        the_name = f"{event['title']} ({days_translation})"
                    else:
                        the_name = event['title']
                    the_date = event['begin'].format(self.date_format, locale=lang)
                    the_time = event['begin'].format(self.time_format, locale=lang)
                    # logger.debug(f"name:{the_name}   date:{the_date} time:{the_time}")

                    if now < event['end']:
                        canvas.write(
                            xy=event_lines[cursor],
                            box_size=(date_width, line_height),
                            text=the_date,
                            alignment='left',
                        )

                        # Check if event is all day
                        if parser.all_day(event):
                            canvas.write(
                                xy=(date_width, event_lines[cursor][1]),
                                box_size= (event_width_l, line_height),
                                text=the_name,
                                alignment='left',
                            )
                        else:
                            canvas.write(
                                xy=(date_width, event_lines[cursor][1]),
                                box_size=(time_width, line_height),
                                text=the_time,
                                alignment='left',
                            )

                            canvas.write(
                                xy=(date_width + time_width, event_lines[cursor][1]),
                                box_size=(event_width_s, line_height),
                                text=the_name,
                                alignment='left',
                            )
                        cursor += 1
        else:
            symbol = '- '
            length = canvas.get_text_width(symbol)
            multiplier = int(im_width // length)
            symbol = symbol * multiplier

            canvas.write(
                xy=event_lines[0],
                box_size=(im_width, line_height),
                text=symbol,
                alignment='left',
                )

    # return the images ready for the display
    return canvas.image_black, canvas.image_colour

Weather

Inkycal weather module Copyright by aceinnolab

Weather

Bases: InkycalModule

Weather class parses weather details from openweathermap

Source code in inkycal/modules/inkycal_weather.py
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
class Weather(InkycalModule):
    """Weather class
    parses weather details from openweathermap
    """
    name = "Weather (openweathermap) - Get weather forecasts from openweathermap"

    requires = {

        "api_key": {
            "label": "Please enter openweathermap api-key. You can create one for free on openweathermap",
        },

        "location": {
            "label": "Please enter your location in the following format: City, Country-Code. " +
                     "You can also enter the location ID found in the url " +
                     "e.g. https://openweathermap.org/city/4893171 -> ID is 4893171"
        }
    }

    optional = {

        "round_temperature": {
            "label": "Round temperature to the nearest degree?",
            "options": [True, False],
        },

        "round_wind_speed": {
            "label": "Round windspeed?",
            "options": [True, False],
        },

        "forecast_interval": {
            "label": "Please select the forecast interval",
            "options": ["daily", "hourly"],
        },

        "units": {
            "label": "Which units should be used?",
            "options": ["metric", "imperial"],
        },

        "hour_format": {
            "label": "Which hour format do you prefer?",
            "options": [24, 12],
        },

        "use_beaufort": {
            "label": "Use beaufort scale for windspeed?",
            "options": [True, False],
        },

    }

    def __init__(self, config):
        """Initialize inkycal_weather module"""

        super().__init__(config)

        config = config['config']

        self.timezone = get_system_tz()

        # Check if all required parameters are present
        for param in self.requires:
            if param not in config:
                raise Exception(f'config is missing {param}')

        # required parameters
        self.api_key = config['api_key']
        self.location = config['location']

        # optional parameters
        self.round_temperature = config['round_temperature']
        self.round_wind_speed = config['round_windspeed']
        self.forecast_interval = config['forecast_interval']
        self.hour_format = int(config['hour_format'])
        if config['units'] == "imperial":
            self.temp_unit = "fahrenheit"
        else:
            self.temp_unit = "celsius"

        if config['use_beaufort']:
            self.wind_unit = "beaufort"
        elif config['units'] == "imperial":
            self.wind_unit = "miles_hour"
        else:
            self.wind_unit = "meters_sec"
        self.locale = config['language']
        # additional configuration

        self.owm = OpenWeatherMap(
            api_key=self.api_key,
            city_id=self.location,
            wind_unit=self.wind_unit,
            temp_unit=self.temp_unit,
            language=self.locale,
            tz_name=self.timezone
        )

        self.weatherfont= FONTS.weather_icons

        if self.wind_unit == "beaufort":
            self.windDispUnit = "bft"
        elif self.wind_unit == "knots":
            self.windDispUnit = "kn"
        elif self.wind_unit == "km_hour":
            self.windDispUnit = "km/h"
        elif self.wind_unit == "miles_hour":
            self.windDispUnit = "mph"
        else:
            self.windDispUnit = "m/s"
        if self.temp_unit == "fahrenheit":
            self.tempDispUnit = "F"
        elif self.temp_unit == "celsius":
            self.tempDispUnit = "°"

        # give an OK message
        logger.debug(f"{__name__} loaded")

    def generate_image(self):
        """Generate image for this module"""

        # Define new image size with respect to padding
        im_width = int(self.width - (2 * self.padding_left))
        im_height = int(self.height - (2 * self.padding_top))
        im_size = im_width, im_height
        logger.debug(f'Image size: {im_size}')

        # Create an image for black pixels and one for coloured pixels
        canvas = Canvas(im_size=im_size, font=self.font, font_size=self.fontsize)

        # Check if internet is available
        if internet_available():
            logger.debug('Connection test passed')
        else:
            logger.error("Network not reachable. Please check your connection.")
            raise NetworkNotReachableError

        def get_moon_phase():
            """Calculate the current (approximate) moon phase

            Returns:
                The corresponding moonphase-icon.
            """

            dec = decimal.Decimal
            diff = now - arrow.get(2001, 1, 1)
            days = dec(diff.days) + (dec(diff.seconds) / dec(86400))
            lunations = dec("0.20439731") + (days * dec("0.03386319269"))
            position = lunations % dec(1)
            index = math.floor((position * dec(8)) + dec("0.5"))
            return {
                0: '\uf095',
                1: '\uf099',
                2: '\uf09c',
                3: '\uf0a0',
                4: '\uf0a3',
                5: '\uf0a7',
                6: '\uf0aa',
                7: '\uf0ae'
            }[int(index) & 7]

        def is_negative(temp: str):
            """Check if temp is below freezing point of water (0°C/32°F)
            returns True if temp below freezing point, else False"""
            answer = False

            if self.temp_unit == 'celsius' and round(float(temp.split(self.tempDispUnit)[0])) <= 0:
                answer = True
            elif self.temp_unit == 'fahrenheit' and round(float(temp.split(self.tempDispUnit)[0])) <= 32:
                answer = True
            return answer

        # Lookup-table for weather icons and weather codes
        weather_icons = {
            '01d': '\uf00d',
            '02d': '\uf002',
            '03d': '\uf013',
            '04d': '\uf012',
            '09d': '\uf01a',
            '10d': '\uf019',
            '11d': '\uf01e',
            '13d': '\uf01b',
            '50d': '\uf014',
            '01n': '\uf02e',
            '02n': '\uf013',
            '03n': '\uf013',
            '04n': '\uf013',
            '09n': '\uf037',
            '10n': '\uf036',
            '11n': '\uf03b',
            '13n': '\uf038',
            '50n': '\uf023'
        }

        #   column1    column2    column3    column4    column5    column6    column7
        # |----------|----------|----------|----------|----------|----------|----------|
        # |  time    | temperat.| moonphase| forecast1| forecast2| forecast3| forecast4|
        # | current  |----------|----------|----------|----------|----------|----------|
        # | weather  | humidity |  sunrise |  icon1   |  icon2   |  icon3   |  icon4   |
        # |  icon    |----------|----------|----------|----------|----------|----------|
        # |          | windspeed|  sunset  | temperat.| temperat.| temperat.| temperat.|
        # |----------|----------|----------|----------|----------|----------|----------|

        # Calculate size rows and columns
        col_width = im_width // 7

        # Ratio width height
        image_ratio = im_width / im_height

        if image_ratio >= 4:
            row_height = im_height // 3
        else:
            logger.info('Please consider decreasing the height.')
            row_height = int((im_height * (1 - im_height / im_width)) / 3)

        logger.debug(f"row_height: {row_height} | col_width: {col_width}")

        # Calculate spacings for better centering
        spacing_top = int((im_width % col_width) / 2)

        # Define sizes for weather icons
        icon_small = int(col_width / 3)

        # Calculate the x-axis position of each col
        col1 = spacing_top
        col2 = col1 + col_width
        col3 = col2 + col_width
        col4 = col3 + col_width
        col5 = col4 + col_width
        col6 = col5 + col_width
        col7 = col6 + col_width

        # Calculate the y-axis position of each row
        line_gap = int((im_height - spacing_top - 3 * row_height) // 4)

        row1 = line_gap
        row2 = row1 + line_gap + row_height
        row3 = row2 + line_gap + row_height

        # Draw lines on each row and border
        # draw = ImageDraw.Draw(canvas.image_black)
        # draw.line((0, 0, im_width, 0), fill='red')
        # draw.line((0, im_height-1, im_width, im_height-1), fill='red')
        # draw.line((0, row1, im_width, row1), fill='black')
        # draw.line((0, row1+row_height, im_width, row1+row_height), fill='black')
        # draw.line((0, row2, im_width, row2), fill='black')
        # draw.line((0, row2+row_height, im_width, row2+row_height), fill='black')
        # draw.line((0, row3, im_width, row3), fill='black')
        # draw.line((0, row3+row_height, im_width, row3+row_height), fill='black')

        # Positions for current weather details
        weather_icon_pos = (col1, 0)
        temperature_icon_pos = (col2, row1)
        temperature_pos = (col2 + icon_small, row1)
        humidity_icon_pos = (col2, row2)
        humidity_pos = (col2 + icon_small, row2)
        windspeed_icon_pos = (col2, row3)
        windspeed_pos = (col2 + icon_small, row3)

        # Positions for sunrise, sunset, moonphase
        moonphase_pos = (col3, row1)
        sunrise_icon_pos = (col3, row2)
        sunrise_time_pos = (col3 + icon_small, row2)
        sunset_icon_pos = (col3, row3)
        sunset_time_pos = (col3 + icon_small, row3)

        # Positions for forecast 1
        stamp_fc1 = (col4, row1) # noqa
        icon_fc1 = (col4, row1 + row_height) # noqa
        temp_fc1 = (col4, row3) # noqa

        # Positions for forecast 2
        stamp_fc2 = (col5, row1) # noqa
        icon_fc2 = (col5, row1 + row_height) # noqa
        temp_fc2 = (col5, row3) # noqa

        # Positions for forecast 3
        stamp_fc3 = (col6, row1) # noqa
        icon_fc3 = (col6, row1 + row_height) # noqa
        temp_fc3 = (col6, row3) # noqa

        # Positions for forecast 4
        stamp_fc4 = (col7, row1) # noqa
        icon_fc4 = (col7, row1 + row_height) # noqa
        temp_fc4 = (col7, row3) # noqa

        # Create current-weather and weather-forecast objects
        logging.debug('looking up location by ID')
        current_weather = self.owm.get_current_weather()
        weather_forecasts = self.owm.get_weather_forecast()

        # Set decimals
        dec_temp = 0 if self.round_temperature == True else 1
        dec_wind = 0 if self.round_wind_speed == True else 1

        logging.debug(f'temperature unit: {self.temp_unit}')
        logging.debug(f'decimals temperature: {dec_temp} | decimals wind: {dec_wind}')

        # Get current time
        now = arrow.utcnow().to(self.timezone)

        fc_data = {}

        if self.forecast_interval == 'hourly':

            logger.debug("getting hourly forecasts")

            # Add next 4 forecasts to fc_data dictionary, since we only have
            fc_data = {}
            for index, forecast in enumerate(weather_forecasts[0:4]):
                fc_data['fc' + str(index + 1)] = {
                    'temp': f"{forecast['temp']:.{dec_temp}f}{self.tempDispUnit}",
                    'icon': forecast["icon"],
                    'stamp': arrow.get(forecast["datetime"]).format("h a" if self.hour_format == 12 else "H:mm")
                }

        elif self.forecast_interval == 'daily':

            logger.debug("getting daily forecasts")

            daily_forecasts = [self.owm.get_forecast_for_day(days) for days in range(1, 5)]

            for index, forecast in enumerate(daily_forecasts):
                fc_data['fc' + str(index + 1)] = {
                    'temp': f'{forecast["temp_min"]:.{dec_temp}f}{self.tempDispUnit}/{forecast["temp_max"]:.{dec_temp}f}{self.tempDispUnit}',
                    'icon': forecast['icon'],
                    'stamp': arrow.get(forecast['datetime']).format("ddd", locale=self.locale)
                }
        else:
            logger.error(f"Invalid forecast interval specified: {self.forecast_interval}. Check your settings!")

        for key, val in fc_data.items():
            logger.debug((key, val))

        # Get some current weather details

        temperature = f"{current_weather['temp']:.{dec_temp}f}{self.tempDispUnit}"

        weather_icon = current_weather["weather_icon_name"]
        humidity = str(current_weather["humidity"])

        sunrise_raw = arrow.get(current_weather["sunrise"]).to(self.timezone)
        sunset_raw = arrow.get(current_weather["sunset"]).to(self.timezone)

        logger.debug(f'weather_icon: {weather_icon}')

        if self.hour_format == 12:
            logger.debug('using 12 hour format for sunrise/sunset')
            sunrise = sunrise_raw.format('h:mm a')
            sunset = sunset_raw.format('h:mm a')
        else:
            # 24 hours format
            logger.debug('using 24 hour format for sunrise/sunset')
            sunrise = sunrise_raw.format('H:mm')
            sunset = sunset_raw.format('H:mm')

        # Format the wind-speed to user preference
        logging.debug(f'getting wind speed in {self.windDispUnit}')
        wind = f"{current_weather['wind']:.{dec_wind}f} {self.windDispUnit}"

        moon_phase = get_moon_phase()

        # Fill weather details in col 1 (current weather icon)
        canvas.draw_icon(
            xy=weather_icon_pos,
            box_size=(col_width, im_height),
            icon=weather_icons[weather_icon],
            colour="colour",
            font=self.weatherfont
        )

        # Fill weather details in col 2 (temp, humidity, wind)
        canvas.draw_icon(
            xy=temperature_icon_pos,
            box_size=(icon_small, row_height),
            icon='\uf053',
            colour="colour",
            font=self.weatherfont
        )
        canvas.write(
            xy=temperature_pos,
            box_size=(col_width - icon_small, row_height),
            text=temperature,
            colour="colour" if is_negative(temperature) else "black"
        )

        canvas.draw_icon(
            xy=humidity_icon_pos,
            box_size=(icon_small, row_height),
            icon='\uf07a',
            colour="colour",
            font=self.weatherfont
        )

        canvas.write(
            xy=humidity_pos,
            box_size=(col_width - icon_small, row_height),
            text=f"{humidity} %",
        )

        canvas.draw_icon(
            xy=windspeed_icon_pos,
            box_size=(icon_small, icon_small),
            icon='\uf050',
            colour="colour",
            font=self.weatherfont
        )

        canvas.write(
            xy=windspeed_pos,
            box_size=(col_width - icon_small, row_height),
            text=wind
        )

        # Fill weather details in col 3 (moonphase, sunrise, sunset)
        canvas.draw_icon(
            xy=moonphase_pos,
            box_size=(col_width, row_height),
            icon=moon_phase,
            colour="colour",
            font=self.weatherfont
        )

        canvas.draw_icon(
            xy=sunrise_icon_pos,
            box_size=(icon_small, icon_small),
            icon='\uf051',
            colour="colour",
            font=self.weatherfont
        )

        canvas.write(
            xy=sunrise_time_pos,
            box_size=(col_width - icon_small, row_height),
            text=sunrise
        )

        canvas.draw_icon(
            xy=sunset_icon_pos,
            box_size=(icon_small, icon_small),
            icon='\uf052',
            colour="colour",
            font=self.weatherfont
        )

        canvas.write(
            xy=sunset_time_pos,
            box_size=(col_width - icon_small, row_height),
            text=sunset
        )
        # Add the forecast data to the correct places
        for pos in range(1, len(fc_data) + 1):
            stamp = fc_data[f'fc{pos}']['stamp']
            # check if we're using daily forecasts
            if "day" in stamp:
                stamp = arrow.get(fc_data[f'fc{pos}']['stamp'], "dddd").format("dddd", locale=self.locale)

            icon = weather_icons[fc_data[f'fc{pos}']['icon']]
            temp = fc_data[f'fc{pos}']['temp']

            canvas.write(
                xy=eval(f'stamp_fc{pos}'),
                box_size=(col_width, row_height),
                text=stamp
            )

            canvas.draw_icon(
                xy=eval(f'icon_fc{pos}'),
                box_size=(col_width, row_height + line_gap * 2),
                icon=icon,
                colour="colour",
                font=self.weatherfont
            )

            canvas.write(
                xy=eval(f'temp_fc{pos}'),
                box_size=(col_width, row_height),
                text=temp
            )

        border_h = row3 + row_height
        border_w = col_width - 3  # leave 3 pixels gap

        # Add borders around each subsection
        draw_border(canvas.image_black, (col1, row1), (col_width * 3 - 3, border_h),
                    shrinkage=(0, 0))

        for _ in range(4, 8):
            draw_border(canvas.image_black, (eval(f'col{_}'), row1), (border_w, border_h),
                        shrinkage=(0, 0))

        # return the images ready for the display
        return canvas.image_black, canvas.image_colour

__init__(config)

Initialize inkycal_weather module

Source code in inkycal/modules/inkycal_weather.py
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
def __init__(self, config):
    """Initialize inkycal_weather module"""

    super().__init__(config)

    config = config['config']

    self.timezone = get_system_tz()

    # Check if all required parameters are present
    for param in self.requires:
        if param not in config:
            raise Exception(f'config is missing {param}')

    # required parameters
    self.api_key = config['api_key']
    self.location = config['location']

    # optional parameters
    self.round_temperature = config['round_temperature']
    self.round_wind_speed = config['round_windspeed']
    self.forecast_interval = config['forecast_interval']
    self.hour_format = int(config['hour_format'])
    if config['units'] == "imperial":
        self.temp_unit = "fahrenheit"
    else:
        self.temp_unit = "celsius"

    if config['use_beaufort']:
        self.wind_unit = "beaufort"
    elif config['units'] == "imperial":
        self.wind_unit = "miles_hour"
    else:
        self.wind_unit = "meters_sec"
    self.locale = config['language']
    # additional configuration

    self.owm = OpenWeatherMap(
        api_key=self.api_key,
        city_id=self.location,
        wind_unit=self.wind_unit,
        temp_unit=self.temp_unit,
        language=self.locale,
        tz_name=self.timezone
    )

    self.weatherfont= FONTS.weather_icons

    if self.wind_unit == "beaufort":
        self.windDispUnit = "bft"
    elif self.wind_unit == "knots":
        self.windDispUnit = "kn"
    elif self.wind_unit == "km_hour":
        self.windDispUnit = "km/h"
    elif self.wind_unit == "miles_hour":
        self.windDispUnit = "mph"
    else:
        self.windDispUnit = "m/s"
    if self.temp_unit == "fahrenheit":
        self.tempDispUnit = "F"
    elif self.temp_unit == "celsius":
        self.tempDispUnit = "°"

    # give an OK message
    logger.debug(f"{__name__} loaded")

generate_image()

Generate image for this module

Source code in inkycal/modules/inkycal_weather.py
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
def generate_image(self):
    """Generate image for this module"""

    # Define new image size with respect to padding
    im_width = int(self.width - (2 * self.padding_left))
    im_height = int(self.height - (2 * self.padding_top))
    im_size = im_width, im_height
    logger.debug(f'Image size: {im_size}')

    # Create an image for black pixels and one for coloured pixels
    canvas = Canvas(im_size=im_size, font=self.font, font_size=self.fontsize)

    # Check if internet is available
    if internet_available():
        logger.debug('Connection test passed')
    else:
        logger.error("Network not reachable. Please check your connection.")
        raise NetworkNotReachableError

    def get_moon_phase():
        """Calculate the current (approximate) moon phase

        Returns:
            The corresponding moonphase-icon.
        """

        dec = decimal.Decimal
        diff = now - arrow.get(2001, 1, 1)
        days = dec(diff.days) + (dec(diff.seconds) / dec(86400))
        lunations = dec("0.20439731") + (days * dec("0.03386319269"))
        position = lunations % dec(1)
        index = math.floor((position * dec(8)) + dec("0.5"))
        return {
            0: '\uf095',
            1: '\uf099',
            2: '\uf09c',
            3: '\uf0a0',
            4: '\uf0a3',
            5: '\uf0a7',
            6: '\uf0aa',
            7: '\uf0ae'
        }[int(index) & 7]

    def is_negative(temp: str):
        """Check if temp is below freezing point of water (0°C/32°F)
        returns True if temp below freezing point, else False"""
        answer = False

        if self.temp_unit == 'celsius' and round(float(temp.split(self.tempDispUnit)[0])) <= 0:
            answer = True
        elif self.temp_unit == 'fahrenheit' and round(float(temp.split(self.tempDispUnit)[0])) <= 32:
            answer = True
        return answer

    # Lookup-table for weather icons and weather codes
    weather_icons = {
        '01d': '\uf00d',
        '02d': '\uf002',
        '03d': '\uf013',
        '04d': '\uf012',
        '09d': '\uf01a',
        '10d': '\uf019',
        '11d': '\uf01e',
        '13d': '\uf01b',
        '50d': '\uf014',
        '01n': '\uf02e',
        '02n': '\uf013',
        '03n': '\uf013',
        '04n': '\uf013',
        '09n': '\uf037',
        '10n': '\uf036',
        '11n': '\uf03b',
        '13n': '\uf038',
        '50n': '\uf023'
    }

    #   column1    column2    column3    column4    column5    column6    column7
    # |----------|----------|----------|----------|----------|----------|----------|
    # |  time    | temperat.| moonphase| forecast1| forecast2| forecast3| forecast4|
    # | current  |----------|----------|----------|----------|----------|----------|
    # | weather  | humidity |  sunrise |  icon1   |  icon2   |  icon3   |  icon4   |
    # |  icon    |----------|----------|----------|----------|----------|----------|
    # |          | windspeed|  sunset  | temperat.| temperat.| temperat.| temperat.|
    # |----------|----------|----------|----------|----------|----------|----------|

    # Calculate size rows and columns
    col_width = im_width // 7

    # Ratio width height
    image_ratio = im_width / im_height

    if image_ratio >= 4:
        row_height = im_height // 3
    else:
        logger.info('Please consider decreasing the height.')
        row_height = int((im_height * (1 - im_height / im_width)) / 3)

    logger.debug(f"row_height: {row_height} | col_width: {col_width}")

    # Calculate spacings for better centering
    spacing_top = int((im_width % col_width) / 2)

    # Define sizes for weather icons
    icon_small = int(col_width / 3)

    # Calculate the x-axis position of each col
    col1 = spacing_top
    col2 = col1 + col_width
    col3 = col2 + col_width
    col4 = col3 + col_width
    col5 = col4 + col_width
    col6 = col5 + col_width
    col7 = col6 + col_width

    # Calculate the y-axis position of each row
    line_gap = int((im_height - spacing_top - 3 * row_height) // 4)

    row1 = line_gap
    row2 = row1 + line_gap + row_height
    row3 = row2 + line_gap + row_height

    # Draw lines on each row and border
    # draw = ImageDraw.Draw(canvas.image_black)
    # draw.line((0, 0, im_width, 0), fill='red')
    # draw.line((0, im_height-1, im_width, im_height-1), fill='red')
    # draw.line((0, row1, im_width, row1), fill='black')
    # draw.line((0, row1+row_height, im_width, row1+row_height), fill='black')
    # draw.line((0, row2, im_width, row2), fill='black')
    # draw.line((0, row2+row_height, im_width, row2+row_height), fill='black')
    # draw.line((0, row3, im_width, row3), fill='black')
    # draw.line((0, row3+row_height, im_width, row3+row_height), fill='black')

    # Positions for current weather details
    weather_icon_pos = (col1, 0)
    temperature_icon_pos = (col2, row1)
    temperature_pos = (col2 + icon_small, row1)
    humidity_icon_pos = (col2, row2)
    humidity_pos = (col2 + icon_small, row2)
    windspeed_icon_pos = (col2, row3)
    windspeed_pos = (col2 + icon_small, row3)

    # Positions for sunrise, sunset, moonphase
    moonphase_pos = (col3, row1)
    sunrise_icon_pos = (col3, row2)
    sunrise_time_pos = (col3 + icon_small, row2)
    sunset_icon_pos = (col3, row3)
    sunset_time_pos = (col3 + icon_small, row3)

    # Positions for forecast 1
    stamp_fc1 = (col4, row1) # noqa
    icon_fc1 = (col4, row1 + row_height) # noqa
    temp_fc1 = (col4, row3) # noqa

    # Positions for forecast 2
    stamp_fc2 = (col5, row1) # noqa
    icon_fc2 = (col5, row1 + row_height) # noqa
    temp_fc2 = (col5, row3) # noqa

    # Positions for forecast 3
    stamp_fc3 = (col6, row1) # noqa
    icon_fc3 = (col6, row1 + row_height) # noqa
    temp_fc3 = (col6, row3) # noqa

    # Positions for forecast 4
    stamp_fc4 = (col7, row1) # noqa
    icon_fc4 = (col7, row1 + row_height) # noqa
    temp_fc4 = (col7, row3) # noqa

    # Create current-weather and weather-forecast objects
    logging.debug('looking up location by ID')
    current_weather = self.owm.get_current_weather()
    weather_forecasts = self.owm.get_weather_forecast()

    # Set decimals
    dec_temp = 0 if self.round_temperature == True else 1
    dec_wind = 0 if self.round_wind_speed == True else 1

    logging.debug(f'temperature unit: {self.temp_unit}')
    logging.debug(f'decimals temperature: {dec_temp} | decimals wind: {dec_wind}')

    # Get current time
    now = arrow.utcnow().to(self.timezone)

    fc_data = {}

    if self.forecast_interval == 'hourly':

        logger.debug("getting hourly forecasts")

        # Add next 4 forecasts to fc_data dictionary, since we only have
        fc_data = {}
        for index, forecast in enumerate(weather_forecasts[0:4]):
            fc_data['fc' + str(index + 1)] = {
                'temp': f"{forecast['temp']:.{dec_temp}f}{self.tempDispUnit}",
                'icon': forecast["icon"],
                'stamp': arrow.get(forecast["datetime"]).format("h a" if self.hour_format == 12 else "H:mm")
            }

    elif self.forecast_interval == 'daily':

        logger.debug("getting daily forecasts")

        daily_forecasts = [self.owm.get_forecast_for_day(days) for days in range(1, 5)]

        for index, forecast in enumerate(daily_forecasts):
            fc_data['fc' + str(index + 1)] = {
                'temp': f'{forecast["temp_min"]:.{dec_temp}f}{self.tempDispUnit}/{forecast["temp_max"]:.{dec_temp}f}{self.tempDispUnit}',
                'icon': forecast['icon'],
                'stamp': arrow.get(forecast['datetime']).format("ddd", locale=self.locale)
            }
    else:
        logger.error(f"Invalid forecast interval specified: {self.forecast_interval}. Check your settings!")

    for key, val in fc_data.items():
        logger.debug((key, val))

    # Get some current weather details

    temperature = f"{current_weather['temp']:.{dec_temp}f}{self.tempDispUnit}"

    weather_icon = current_weather["weather_icon_name"]
    humidity = str(current_weather["humidity"])

    sunrise_raw = arrow.get(current_weather["sunrise"]).to(self.timezone)
    sunset_raw = arrow.get(current_weather["sunset"]).to(self.timezone)

    logger.debug(f'weather_icon: {weather_icon}')

    if self.hour_format == 12:
        logger.debug('using 12 hour format for sunrise/sunset')
        sunrise = sunrise_raw.format('h:mm a')
        sunset = sunset_raw.format('h:mm a')
    else:
        # 24 hours format
        logger.debug('using 24 hour format for sunrise/sunset')
        sunrise = sunrise_raw.format('H:mm')
        sunset = sunset_raw.format('H:mm')

    # Format the wind-speed to user preference
    logging.debug(f'getting wind speed in {self.windDispUnit}')
    wind = f"{current_weather['wind']:.{dec_wind}f} {self.windDispUnit}"

    moon_phase = get_moon_phase()

    # Fill weather details in col 1 (current weather icon)
    canvas.draw_icon(
        xy=weather_icon_pos,
        box_size=(col_width, im_height),
        icon=weather_icons[weather_icon],
        colour="colour",
        font=self.weatherfont
    )

    # Fill weather details in col 2 (temp, humidity, wind)
    canvas.draw_icon(
        xy=temperature_icon_pos,
        box_size=(icon_small, row_height),
        icon='\uf053',
        colour="colour",
        font=self.weatherfont
    )
    canvas.write(
        xy=temperature_pos,
        box_size=(col_width - icon_small, row_height),
        text=temperature,
        colour="colour" if is_negative(temperature) else "black"
    )

    canvas.draw_icon(
        xy=humidity_icon_pos,
        box_size=(icon_small, row_height),
        icon='\uf07a',
        colour="colour",
        font=self.weatherfont
    )

    canvas.write(
        xy=humidity_pos,
        box_size=(col_width - icon_small, row_height),
        text=f"{humidity} %",
    )

    canvas.draw_icon(
        xy=windspeed_icon_pos,
        box_size=(icon_small, icon_small),
        icon='\uf050',
        colour="colour",
        font=self.weatherfont
    )

    canvas.write(
        xy=windspeed_pos,
        box_size=(col_width - icon_small, row_height),
        text=wind
    )

    # Fill weather details in col 3 (moonphase, sunrise, sunset)
    canvas.draw_icon(
        xy=moonphase_pos,
        box_size=(col_width, row_height),
        icon=moon_phase,
        colour="colour",
        font=self.weatherfont
    )

    canvas.draw_icon(
        xy=sunrise_icon_pos,
        box_size=(icon_small, icon_small),
        icon='\uf051',
        colour="colour",
        font=self.weatherfont
    )

    canvas.write(
        xy=sunrise_time_pos,
        box_size=(col_width - icon_small, row_height),
        text=sunrise
    )

    canvas.draw_icon(
        xy=sunset_icon_pos,
        box_size=(icon_small, icon_small),
        icon='\uf052',
        colour="colour",
        font=self.weatherfont
    )

    canvas.write(
        xy=sunset_time_pos,
        box_size=(col_width - icon_small, row_height),
        text=sunset
    )
    # Add the forecast data to the correct places
    for pos in range(1, len(fc_data) + 1):
        stamp = fc_data[f'fc{pos}']['stamp']
        # check if we're using daily forecasts
        if "day" in stamp:
            stamp = arrow.get(fc_data[f'fc{pos}']['stamp'], "dddd").format("dddd", locale=self.locale)

        icon = weather_icons[fc_data[f'fc{pos}']['icon']]
        temp = fc_data[f'fc{pos}']['temp']

        canvas.write(
            xy=eval(f'stamp_fc{pos}'),
            box_size=(col_width, row_height),
            text=stamp
        )

        canvas.draw_icon(
            xy=eval(f'icon_fc{pos}'),
            box_size=(col_width, row_height + line_gap * 2),
            icon=icon,
            colour="colour",
            font=self.weatherfont
        )

        canvas.write(
            xy=eval(f'temp_fc{pos}'),
            box_size=(col_width, row_height),
            text=temp
        )

    border_h = row3 + row_height
    border_w = col_width - 3  # leave 3 pixels gap

    # Add borders around each subsection
    draw_border(canvas.image_black, (col1, row1), (col_width * 3 - 3, border_h),
                shrinkage=(0, 0))

    for _ in range(4, 8):
        draw_border(canvas.image_black, (eval(f'col{_}'), row1), (border_w, border_h),
                    shrinkage=(0, 0))

    # return the images ready for the display
    return canvas.image_black, canvas.image_colour

Feeds

Feeds module for InkyCal Project Copyright by aceinnolab

Feeds

Bases: InkycalModule

RSS class parses rss/atom feeds from given urls

Source code in inkycal/modules/inkycal_feeds.py
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
class Feeds(InkycalModule):
    """RSS class
    parses rss/atom feeds from given urls
    """

    name = "RSS / Atom - Display feeds from given RSS/ATOM feeds"

    requires = {
        "feed_urls": {
            "label": "Please enter ATOM or RSS feed URL/s, separated by a comma",
        },

    }

    optional = {

        "shuffle_feeds": {
            "label": "Should the parsed RSS feeds be shuffled? (default=True)",
            "options": [True, False],
            "default": True
        },

    }

    def __init__(self, config):
        """Initialize inkycal_feeds module"""

        super().__init__(config)

        config = config['config']

        # Check if all required parameters are present
        for param in self.requires:
            if param not in config:
                raise Exception(f'config is missing {param}')

        # required parameters
        if config["feed_urls"] and isinstance(config['feed_urls'], str):
            self.feed_urls = config["feed_urls"].split(",")
        else:
            self.feed_urls = config["feed_urls"]

        # optional parameters
        self.shuffle_feeds = config["shuffle_feeds"]

        # give an OK message
        logger.debug(f'{__name__} loaded')

    def _validate(self):
        """Validate module-specific parameters"""

        if not isinstance(self.shuffle_feeds, bool):
            print('shuffle_feeds has to be a boolean: True/False')

    def generate_image(self):
        """Generate image for this module"""

        # Define new image size with respect to padding
        im_width = int(self.width - (2 * self.padding_left))
        im_height = int(self.height - (2 * self.padding_top))
        im_size = im_width, im_height
        logger.debug(f'Image size: {im_size}')

        canvas = Canvas(im_size, self.font, self.fontsize)

        # Check if internet is available
        if internet_available():
            logger.debug('Connection test passed')
        else:
            logger.error("Network not reachable. Please check your connection.")
            raise NetworkNotReachableError

        # Set some parameters for formatting feeds
        line_spacing = 1

        line_width = im_width
        line_height = canvas.get_line_height() + line_spacing
        max_lines = (im_height // (line_height + line_spacing))

        # Calculate padding from top so the lines look centralised
        spacing_top = int(im_height % line_height / 2)

        # Calculate line_positions
        line_positions = [
            (0, spacing_top + _ * line_height) for _ in range(max_lines)]

        # Create list containing all feeds from all urls
        parsed_feeds = []
        for feeds in self.feed_urls:
            text = feedparser.parse(feeds)
            for posts in text.entries:
                if "summary" in posts:
                    summary = posts["summary"]
                    parsed_feeds.append(f"•{posts.title}: {re.sub('<[^<]+?>', '', posts.summary)}")
                # if "description" in posts:

        if parsed_feeds:
            parsed_feeds = [i.split("\n") for i in parsed_feeds]
            parsed_feeds = [i for i in parsed_feeds if i]

        # Shuffle the list to prevent showing the same content
        if self.shuffle_feeds:
            shuffle(parsed_feeds)

        # Trim down the list to the max number of lines
        del parsed_feeds[max_lines:]

        # Wrap long text from feeds (line-breaking)
        flatten = lambda z: [x for y in z for x in y]
        filtered_feeds, counter = [], 0

        for posts in parsed_feeds:
            wrapped = canvas.text_wrap(posts[0], max_width=line_width)
            counter += len(wrapped)
            if counter < max_lines:
                filtered_feeds.append(wrapped)
        filtered_feeds = flatten(filtered_feeds)

        logger.debug(f'filtered feeds -> {filtered_feeds}')

        # Check if feeds could be parsed and can be displayed
        if len(filtered_feeds) == 0 and len(parsed_feeds) > 0:
            print('Feeds could be parsed, but the text is too long to be displayed:/')
        elif len(filtered_feeds) == 0 and len(parsed_feeds) == 0:
            print('No feeds could be parsed :/')
        else:
            # Write feeds on image
            for _ in range(len(filtered_feeds)):
                canvas.write(
                    xy=line_positions[_],
                    box_size=(line_width, line_height),
                    text=filtered_feeds[_],
                    alignment="left"
                )
        # return images
        return canvas.image_black, canvas.image_colour

__init__(config)

Initialize inkycal_feeds module

Source code in inkycal/modules/inkycal_feeds.py
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
def __init__(self, config):
    """Initialize inkycal_feeds module"""

    super().__init__(config)

    config = config['config']

    # Check if all required parameters are present
    for param in self.requires:
        if param not in config:
            raise Exception(f'config is missing {param}')

    # required parameters
    if config["feed_urls"] and isinstance(config['feed_urls'], str):
        self.feed_urls = config["feed_urls"].split(",")
    else:
        self.feed_urls = config["feed_urls"]

    # optional parameters
    self.shuffle_feeds = config["shuffle_feeds"]

    # give an OK message
    logger.debug(f'{__name__} loaded')

generate_image()

Generate image for this module

Source code in inkycal/modules/inkycal_feeds.py
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
def generate_image(self):
    """Generate image for this module"""

    # Define new image size with respect to padding
    im_width = int(self.width - (2 * self.padding_left))
    im_height = int(self.height - (2 * self.padding_top))
    im_size = im_width, im_height
    logger.debug(f'Image size: {im_size}')

    canvas = Canvas(im_size, self.font, self.fontsize)

    # Check if internet is available
    if internet_available():
        logger.debug('Connection test passed')
    else:
        logger.error("Network not reachable. Please check your connection.")
        raise NetworkNotReachableError

    # Set some parameters for formatting feeds
    line_spacing = 1

    line_width = im_width
    line_height = canvas.get_line_height() + line_spacing
    max_lines = (im_height // (line_height + line_spacing))

    # Calculate padding from top so the lines look centralised
    spacing_top = int(im_height % line_height / 2)

    # Calculate line_positions
    line_positions = [
        (0, spacing_top + _ * line_height) for _ in range(max_lines)]

    # Create list containing all feeds from all urls
    parsed_feeds = []
    for feeds in self.feed_urls:
        text = feedparser.parse(feeds)
        for posts in text.entries:
            if "summary" in posts:
                summary = posts["summary"]
                parsed_feeds.append(f"•{posts.title}: {re.sub('<[^<]+?>', '', posts.summary)}")
            # if "description" in posts:

    if parsed_feeds:
        parsed_feeds = [i.split("\n") for i in parsed_feeds]
        parsed_feeds = [i for i in parsed_feeds if i]

    # Shuffle the list to prevent showing the same content
    if self.shuffle_feeds:
        shuffle(parsed_feeds)

    # Trim down the list to the max number of lines
    del parsed_feeds[max_lines:]

    # Wrap long text from feeds (line-breaking)
    flatten = lambda z: [x for y in z for x in y]
    filtered_feeds, counter = [], 0

    for posts in parsed_feeds:
        wrapped = canvas.text_wrap(posts[0], max_width=line_width)
        counter += len(wrapped)
        if counter < max_lines:
            filtered_feeds.append(wrapped)
    filtered_feeds = flatten(filtered_feeds)

    logger.debug(f'filtered feeds -> {filtered_feeds}')

    # Check if feeds could be parsed and can be displayed
    if len(filtered_feeds) == 0 and len(parsed_feeds) > 0:
        print('Feeds could be parsed, but the text is too long to be displayed:/')
    elif len(filtered_feeds) == 0 and len(parsed_feeds) == 0:
        print('No feeds could be parsed :/')
    else:
        # Write feeds on image
        for _ in range(len(filtered_feeds)):
            canvas.write(
                xy=line_positions[_],
                box_size=(line_width, line_height),
                text=filtered_feeds[_],
                alignment="left"
            )
    # return images
    return canvas.image_black, canvas.image_colour

Stocks

Stocks Module for Inkycal Project

Version 0.6: Dropped matplotlib dependency in favour of render_line_chart function Version 0.5: Added improved precision by using new priceHint parameter of yfinance Version 0.4: Added charts Version 0.3: Added support for web-UI of Inkycal 2.0.0 Version 0.2: Migration to Inkycal 2.0.0 Version 0.1: Migration to Inkycal 2.0.0b

by https://github.com/worstface

Stocks

Bases: InkycalModule

Source code in inkycal/modules/inkycal_stocks.py
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
class Stocks(InkycalModule):
    name = "Stocks - Displays stock market infos from Yahoo finance"

    # required parameters
    requires = {

        "tickers": {

            "label": "You can display any information by using "
                     "the respective symbols that are used by Yahoo! Finance. "
                     "Separate multiple symbols with a comma sign e.g. "
                     "TSLA, U, NVDA, EURUSD=X"
        }
    }

    def __init__(self, config):

        super().__init__(config)

        config = config['config']

        # If tickers is a string from web-ui, convert to a list, else use
        # tickers as-is i.e. for tests
        if config['tickers'] and isinstance(config['tickers'], str):
            self.tickers = config['tickers'].replace(" ", "").split(',')  # returns list
        else:
            self.tickers = config['tickers']

        # give an OK message
        logger.debug(f'{__name__} loaded')

    def generate_image(self):
        """Generate image for this module"""

        # Define new image size with respect to padding
        im_width = int(self.width - (2 * self.padding_left))
        im_height = int(self.height - (2 * self.padding_top))
        im_size = im_width, im_height
        logger.debug(f'image size: {im_width} x {im_height} px')

        canvas = Canvas(im_size=im_size, font=self.font, font_size=self.fontsize)

        # Create tmp path
        tmpPath = 'temp/'

        if not os.path.exists(tmpPath):
            print(f"Creating tmp directory {tmpPath}")
            os.mkdir(tmpPath)

        # Check if internet is available
        if internet_available():
            logger.info('Connection test passed')
        else:
            raise Exception('Network could not be reached :/')

        # Set some parameters for formatting feeds
        line_spacing = 1
        line_height = canvas.get_line_height()
        line_width = im_width
        max_lines = (im_height // (line_height + line_spacing))

        logger.debug(f"max_lines: {max_lines}")

        # Calculate padding from top so the lines look centralised
        spacing_top = int(im_height % line_height / 2)

        # Calculate line_positions
        line_positions = [
            (0, spacing_top + _ * line_height) for _ in range(max_lines)]

        logger.debug(f'line positions: {line_positions}')

        parsed_tickers = []
        parsed_tickers_colour = []
        chartSpace = Image.new('RGBA', (im_width, im_height), "white")
        chartSpace_colour = Image.new('RGBA', (im_width, im_height), "white")

        tickerCount = range(len(self.tickers))

        for _ in tickerCount:
            ticker = self.tickers[_]
            logger.info(f'preparing data for {ticker}...')

            yfTicker = yf.Ticker(ticker)

            try:
                stockInfo = yfTicker.info
            except Exception as exceptionMessage:
                logger.warning(f"Failed to get '{ticker}' ticker info: {exceptionMessage}")

            try:
                stockName = stockInfo['shortName']
            except Exception:
                stockName = ticker
                logger.warning(f"Failed to get '{stockName}' ticker name! Using "
                               "the ticker symbol as name instead.")

            try:
                stockCurrency = stockInfo['currency']
                if stockCurrency == 'USD':
                    stockCurrency = '$'
                elif stockCurrency == 'EUR':
                    stockCurrency = '€'
            except Exception:
                stockCurrency = ''
                logger.warning(f"Failed to get ticker currency!")

            try:
                precision = stockInfo['priceHint']
            except Exception:
                precision = 2
                logger.warning(f"Failed to get '{stockName}' ticker price hint! Using "
                               "default precision of 2 instead.")

            stockHistory = yfTicker.history("1mo")
            stockHistoryLen = len(stockHistory)
            logger.info(f'fetched {stockHistoryLen} datapoints ...')
            previousQuote = (stockHistory.tail(2)['Close'].iloc[0])
            currentQuote = (stockHistory.tail(1)['Close'].iloc[0])
            currentHigh = (stockHistory.tail(1)['High'].iloc[0])
            currentLow = (stockHistory.tail(1)['Low'].iloc[0])
            currentOpen = (stockHistory.tail(1)['Open'].iloc[0])
            currentGain = currentQuote - previousQuote
            currentGainPercentage = (1 - currentQuote / previousQuote) * -100
            firstQuote = stockHistory.tail(stockHistoryLen)['Close'].iloc[0]
            logger.info(f'firstQuote {firstQuote} ...')

            def floatStr(precision, number):
                return "%0.*f" % (precision, number)

            def percentageStr(number):
                return '({:+.2f}%)'.format(number)

            def gainStr(precision, number):
                return "%+.*f" % (precision, number)

            stockNameLine = '{} ({})'.format(stockName, stockCurrency)
            stockCurrentValueLine = '{} {} {}'.format(
                floatStr(precision, currentQuote), gainStr(precision, currentGain),
                percentageStr(currentGainPercentage))
            stockDayValueLine = '1d OHL: {}/{}/{}'.format(
                floatStr(precision, currentOpen), floatStr(precision, currentHigh), floatStr(precision, currentLow))
            maxQuote = max(stockHistory.High)
            minQuote = min(stockHistory.Low)
            logger.info(f'high {maxQuote} low {minQuote} ...')
            stockMonthValueLine = '{}d OHL: {}/{}/{}'.format(
                stockHistoryLen, floatStr(precision, firstQuote), floatStr(precision, maxQuote),
                floatStr(precision, minQuote))

            logger.info(stockNameLine)
            logger.info(stockCurrentValueLine)
            logger.info(stockDayValueLine)
            logger.info(stockMonthValueLine)
            parsed_tickers.append(stockNameLine)
            parsed_tickers.append(stockCurrentValueLine)
            parsed_tickers.append(stockDayValueLine)
            parsed_tickers.append(stockMonthValueLine)

            parsed_tickers_colour.append("")
            if currentGain < 0:
                parsed_tickers_colour.append(stockCurrentValueLine)
            else:
                parsed_tickers_colour.append("")
            if currentOpen > currentQuote:
                parsed_tickers_colour.append(stockDayValueLine)
            else:
                parsed_tickers_colour.append("")
            if firstQuote > currentQuote:
                parsed_tickers_colour.append(stockMonthValueLine)
            else:
                parsed_tickers_colour.append("")

            if _ < len(tickerCount):
                parsed_tickers.append("")
                parsed_tickers_colour.append("")

            logger.info(f'creating chart data...')
            chartData = stockHistory.reset_index()
            chartCloseData = chartData.loc[:, 'Close']
            chartTimeData = chartData.loc[:, 'Date']

            logger.info('creating chart plot with Pillow...')
            # We only need the Close series; time axis is implicit (index)
            close_values = list(chartCloseData)

            # Decide chart size — similar to your thumbnail size
            chart_w = int(im_width / 4)
            chart_h = int(line_height * 4)

            chartImage = render_line_chart(
                values=close_values,
                size=(chart_w, chart_h),
                line_width=2,
                line_color="black",
                bg_color="white",
                padding=2,
            )

            logger.info(f'chartSpace is...{im_width} {im_height}')
            chartPasteX = im_width - chartImage.width
            chartPasteY = line_height * 5 * _
            logger.info(f'pasting chart image with index {_} to...{chartPasteX} {chartPasteY}')

            if firstQuote > currentQuote:
                chartSpace_colour.paste(chartImage, (chartPasteX, chartPasteY))
            else:
                chartSpace.paste(chartImage, (chartPasteX, chartPasteY))
        canvas.image_black.paste(chartSpace)
        canvas.image_colour.paste(chartSpace_colour)

        # Write/Draw something on the black image
        for _ in range(len(parsed_tickers)):
            if _ + 1 > max_lines:
                logger.error('Ran out of lines for parsed_ticker_colour')
                break
            canvas.write(
                xy=line_positions[_],
                box_size= (line_width, line_height),
                text=parsed_tickers[_],
                alignment='left'
            )

        # Write/Draw something on the colour image
        for _ in range(len(parsed_tickers_colour)):
            if _ + 1 > max_lines:
                logger.error('Ran out of lines for parsed_tickers_colour')
                break
            canvas.write(
                xy=line_positions[_],
                box_size= (line_width, line_height),
                text=parsed_tickers[_],
                alignment='left'
            )

        # Save image of black and colour channel in image-folder
        return canvas.image_black, canvas.image_colour

generate_image()

Generate image for this module

Source code in inkycal/modules/inkycal_stocks.py
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
def generate_image(self):
    """Generate image for this module"""

    # Define new image size with respect to padding
    im_width = int(self.width - (2 * self.padding_left))
    im_height = int(self.height - (2 * self.padding_top))
    im_size = im_width, im_height
    logger.debug(f'image size: {im_width} x {im_height} px')

    canvas = Canvas(im_size=im_size, font=self.font, font_size=self.fontsize)

    # Create tmp path
    tmpPath = 'temp/'

    if not os.path.exists(tmpPath):
        print(f"Creating tmp directory {tmpPath}")
        os.mkdir(tmpPath)

    # Check if internet is available
    if internet_available():
        logger.info('Connection test passed')
    else:
        raise Exception('Network could not be reached :/')

    # Set some parameters for formatting feeds
    line_spacing = 1
    line_height = canvas.get_line_height()
    line_width = im_width
    max_lines = (im_height // (line_height + line_spacing))

    logger.debug(f"max_lines: {max_lines}")

    # Calculate padding from top so the lines look centralised
    spacing_top = int(im_height % line_height / 2)

    # Calculate line_positions
    line_positions = [
        (0, spacing_top + _ * line_height) for _ in range(max_lines)]

    logger.debug(f'line positions: {line_positions}')

    parsed_tickers = []
    parsed_tickers_colour = []
    chartSpace = Image.new('RGBA', (im_width, im_height), "white")
    chartSpace_colour = Image.new('RGBA', (im_width, im_height), "white")

    tickerCount = range(len(self.tickers))

    for _ in tickerCount:
        ticker = self.tickers[_]
        logger.info(f'preparing data for {ticker}...')

        yfTicker = yf.Ticker(ticker)

        try:
            stockInfo = yfTicker.info
        except Exception as exceptionMessage:
            logger.warning(f"Failed to get '{ticker}' ticker info: {exceptionMessage}")

        try:
            stockName = stockInfo['shortName']
        except Exception:
            stockName = ticker
            logger.warning(f"Failed to get '{stockName}' ticker name! Using "
                           "the ticker symbol as name instead.")

        try:
            stockCurrency = stockInfo['currency']
            if stockCurrency == 'USD':
                stockCurrency = '$'
            elif stockCurrency == 'EUR':
                stockCurrency = '€'
        except Exception:
            stockCurrency = ''
            logger.warning(f"Failed to get ticker currency!")

        try:
            precision = stockInfo['priceHint']
        except Exception:
            precision = 2
            logger.warning(f"Failed to get '{stockName}' ticker price hint! Using "
                           "default precision of 2 instead.")

        stockHistory = yfTicker.history("1mo")
        stockHistoryLen = len(stockHistory)
        logger.info(f'fetched {stockHistoryLen} datapoints ...')
        previousQuote = (stockHistory.tail(2)['Close'].iloc[0])
        currentQuote = (stockHistory.tail(1)['Close'].iloc[0])
        currentHigh = (stockHistory.tail(1)['High'].iloc[0])
        currentLow = (stockHistory.tail(1)['Low'].iloc[0])
        currentOpen = (stockHistory.tail(1)['Open'].iloc[0])
        currentGain = currentQuote - previousQuote
        currentGainPercentage = (1 - currentQuote / previousQuote) * -100
        firstQuote = stockHistory.tail(stockHistoryLen)['Close'].iloc[0]
        logger.info(f'firstQuote {firstQuote} ...')

        def floatStr(precision, number):
            return "%0.*f" % (precision, number)

        def percentageStr(number):
            return '({:+.2f}%)'.format(number)

        def gainStr(precision, number):
            return "%+.*f" % (precision, number)

        stockNameLine = '{} ({})'.format(stockName, stockCurrency)
        stockCurrentValueLine = '{} {} {}'.format(
            floatStr(precision, currentQuote), gainStr(precision, currentGain),
            percentageStr(currentGainPercentage))
        stockDayValueLine = '1d OHL: {}/{}/{}'.format(
            floatStr(precision, currentOpen), floatStr(precision, currentHigh), floatStr(precision, currentLow))
        maxQuote = max(stockHistory.High)
        minQuote = min(stockHistory.Low)
        logger.info(f'high {maxQuote} low {minQuote} ...')
        stockMonthValueLine = '{}d OHL: {}/{}/{}'.format(
            stockHistoryLen, floatStr(precision, firstQuote), floatStr(precision, maxQuote),
            floatStr(precision, minQuote))

        logger.info(stockNameLine)
        logger.info(stockCurrentValueLine)
        logger.info(stockDayValueLine)
        logger.info(stockMonthValueLine)
        parsed_tickers.append(stockNameLine)
        parsed_tickers.append(stockCurrentValueLine)
        parsed_tickers.append(stockDayValueLine)
        parsed_tickers.append(stockMonthValueLine)

        parsed_tickers_colour.append("")
        if currentGain < 0:
            parsed_tickers_colour.append(stockCurrentValueLine)
        else:
            parsed_tickers_colour.append("")
        if currentOpen > currentQuote:
            parsed_tickers_colour.append(stockDayValueLine)
        else:
            parsed_tickers_colour.append("")
        if firstQuote > currentQuote:
            parsed_tickers_colour.append(stockMonthValueLine)
        else:
            parsed_tickers_colour.append("")

        if _ < len(tickerCount):
            parsed_tickers.append("")
            parsed_tickers_colour.append("")

        logger.info(f'creating chart data...')
        chartData = stockHistory.reset_index()
        chartCloseData = chartData.loc[:, 'Close']
        chartTimeData = chartData.loc[:, 'Date']

        logger.info('creating chart plot with Pillow...')
        # We only need the Close series; time axis is implicit (index)
        close_values = list(chartCloseData)

        # Decide chart size — similar to your thumbnail size
        chart_w = int(im_width / 4)
        chart_h = int(line_height * 4)

        chartImage = render_line_chart(
            values=close_values,
            size=(chart_w, chart_h),
            line_width=2,
            line_color="black",
            bg_color="white",
            padding=2,
        )

        logger.info(f'chartSpace is...{im_width} {im_height}')
        chartPasteX = im_width - chartImage.width
        chartPasteY = line_height * 5 * _
        logger.info(f'pasting chart image with index {_} to...{chartPasteX} {chartPasteY}')

        if firstQuote > currentQuote:
            chartSpace_colour.paste(chartImage, (chartPasteX, chartPasteY))
        else:
            chartSpace.paste(chartImage, (chartPasteX, chartPasteY))
    canvas.image_black.paste(chartSpace)
    canvas.image_colour.paste(chartSpace_colour)

    # Write/Draw something on the black image
    for _ in range(len(parsed_tickers)):
        if _ + 1 > max_lines:
            logger.error('Ran out of lines for parsed_ticker_colour')
            break
        canvas.write(
            xy=line_positions[_],
            box_size= (line_width, line_height),
            text=parsed_tickers[_],
            alignment='left'
        )

    # Write/Draw something on the colour image
    for _ in range(len(parsed_tickers_colour)):
        if _ + 1 > max_lines:
            logger.error('Ran out of lines for parsed_tickers_colour')
            break
        canvas.write(
            xy=line_positions[_],
            box_size= (line_width, line_height),
            text=parsed_tickers[_],
            alignment='left'
        )

    # Save image of black and colour channel in image-folder
    return canvas.image_black, canvas.image_colour

Image Module

Inkycal Image Module Copyright by aceinnolab

Inkyimage

Bases: InkycalModule

Displays an image from URL or local path

Source code in inkycal/modules/inkycal_image.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
class Inkyimage(InkycalModule):
    """Displays an image from URL or local path"""

    name = "Inkycal Image - show an image from a URL or local path"

    requires = {
        "path": {
            "label": "Path to a local folder, e.g. /home/pi/Desktop/images. "
            "Only PNG and JPG/JPEG images are used for the slideshow."
        },
        "palette": {"label": "Which palette should be used for converting images?", "options": ["bw", "bwr", "bwy"]},
    }

    optional = {
        "autoflip": {"label": "Should the image be flipped automatically?", "options": [True, False]},
        "orientation": {"label": "Please select the desired orientation", "options": ["vertical", "horizontal"]},
    }

    def __init__(self, config):
        """Initialize module"""

        super().__init__(config)

        config = config["config"]

        # required parameters
        for param in self.requires:
            if not param in config:
                raise Exception(f"config is missing {param}")

        # optional parameters
        self.path = config["path"]
        self.palette = config["palette"]
        self.autoflip = config["autoflip"]
        self.orientation = config["orientation"]
        self.dither = True
        if "dither" in config and config["dither"] == False:
            self.dither = False

        # give an OK message
        logger.debug(f"{__name__} loaded")

    def generate_image(self):
        """Generate image for this module"""

        # Define new image size with respect to padding
        im_width = int(self.width - (2 * self.padding_left))
        im_height = int(self.height - (2 * self.padding_top))
        im_size = im_width, im_height

        logger.info(f"Image size: {im_size}")

        # initialize custom image class
        im = Images()

        # use the image at the first index
        im.load(self.path)

        # Remove background if present
        im.remove_alpha()

        # if auto-flip was enabled, flip the image
        if self.autoflip:
            im.autoflip(self.orientation)

        # resize the image so it can fit on the epaper
        im.resize(width=im_width, height=im_height)

        # convert images according to specified palette
        im_black, im_colour = image_to_palette(image=im.image.convert("RGB"), palette=self.palette, dither=self.dither)

        # with the images now send, clear the current image
        im.clear()

        # return images
        return im_black, im_colour

__init__(config)

Initialize module

Source code in inkycal/modules/inkycal_image.py
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
def __init__(self, config):
    """Initialize module"""

    super().__init__(config)

    config = config["config"]

    # required parameters
    for param in self.requires:
        if not param in config:
            raise Exception(f"config is missing {param}")

    # optional parameters
    self.path = config["path"]
    self.palette = config["palette"]
    self.autoflip = config["autoflip"]
    self.orientation = config["orientation"]
    self.dither = True
    if "dither" in config and config["dither"] == False:
        self.dither = False

    # give an OK message
    logger.debug(f"{__name__} loaded")

generate_image()

Generate image for this module

Source code in inkycal/modules/inkycal_image.py
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
def generate_image(self):
    """Generate image for this module"""

    # Define new image size with respect to padding
    im_width = int(self.width - (2 * self.padding_left))
    im_height = int(self.height - (2 * self.padding_top))
    im_size = im_width, im_height

    logger.info(f"Image size: {im_size}")

    # initialize custom image class
    im = Images()

    # use the image at the first index
    im.load(self.path)

    # Remove background if present
    im.remove_alpha()

    # if auto-flip was enabled, flip the image
    if self.autoflip:
        im.autoflip(self.orientation)

    # resize the image so it can fit on the epaper
    im.resize(width=im_width, height=im_height)

    # convert images according to specified palette
    im_black, im_colour = image_to_palette(image=im.image.convert("RGB"), palette=self.palette, dither=self.dither)

    # with the images now send, clear the current image
    im.clear()

    # return images
    return im_black, im_colour

Agenda

Inkycal Agenda Module Copyright by aceinnolab

Agenda

Bases: InkycalModule

Agenda class Create agenda and show events from given icalendars

Source code in inkycal/modules/inkycal_agenda.py
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
class Agenda(InkycalModule):
    """Agenda class
    Create agenda and show events from given icalendars
    """

    name = "Agenda - Display upcoming events from given iCalendars"

    requires = {
        "ical_urls": {
            "label": "iCalendar URL/s, separate multiple ones with a comma",
        },

    }

    optional = {
        "ical_files": {
            "label": "iCalendar filepaths, separated with a comma",
        },

        "date_format": {
            "label": "Use an arrow-supported token for custom date formatting " +
                     "see https://arrow.readthedocs.io/en/stable/#supported-tokens, e.g. ddd D MMM",
            "default": "ddd D MMM",
        },

        "time_format": {
            "label": "Use an arrow-supported token for custom time formatting " +
                     "see https://arrow.readthedocs.io/en/stable/#supported-tokens, e.g. HH:mm",
            "default": "HH:mm",
        },

    }

    def __init__(self, config):
        """Initialize inkycal_agenda module"""

        super().__init__(config)

        config = config['config']

        # Check if all required parameters are present
        for param in self.requires:
            if param not in config:
                raise Exception(f'config is missing {param}')

        # module specific parameters
        self.date_format = config['date_format']
        self.time_format = config['time_format']
        self.language = config['language']

        # Check if ical_files is an empty string
        if config['ical_urls'] and isinstance(config['ical_urls'], str):
            self.ical_urls = config['ical_urls'].split(',')
        else:
            self.ical_urls = config['ical_urls']

        # Check if ical_files is an empty string
        if config['ical_files'] and isinstance(config['ical_files'], str):
            self.ical_files = config['ical_files'].split(',')
        else:
            self.ical_files = config['ical_files']

        # Additional config
        self.timezone = get_system_tz()

        self.icon_font = FONTS.material_icons

        # give an OK message
        logger.debug(f'{__name__} loaded')

    def generate_image(self):
        """Generate image for this module"""

        # Define new image size with respect to padding
        im_width = int(self.width - (2 * self.padding_left))
        im_height = int(self.height - (2 * self.padding_top))
        im_size = im_width, im_height

        logger.debug(f'Image size: {im_size}')

        canvas = Canvas(im_size, self.font, self.fontsize)

        # Calculate the max number of lines that can fit on the image
        line_spacing = 1

        line_height = canvas.get_line_height() + line_spacing
        max_lines = im_height // line_height
        logger.debug(f'max lines: {max_lines}')

        # Create timeline for agenda
        now = arrow.now()
        today = now.floor('day')

        # Create a list of dates for the next days
        agenda_events = [
            {
                'begin': today.shift(days=+_),
                'title': today.shift(days=+_).format(
                    self.date_format, locale=self.language)
            }
            for _ in range(max_lines)]

        # Load icalendar from config
        ical = iCalendar()

        if self.ical_urls:
            ical.load_url(self.ical_urls)

        if self.ical_files:
            ical.load_from_file(self.ical_files)

        # Load events from all icalendar in timerange
        upcoming_events = ical.get_events(today, agenda_events[-1]['begin'],
                                            self.timezone)

        # Sort events by beginning time
        ical.sort()
        # parser.show_events()

        # Set the width for date, time and event titles
        date_strings = [date['begin'].format(self.date_format, locale=self.language) for date in agenda_events]
        longest_date = max(date_strings, key=len)

        date_width = canvas.get_text_width(longest_date)
        logger.debug(f'date_width: {date_width}')

        # Calculate positions for each line
        line_pos = [(0, int(line * line_height)) for line in range(max_lines)]
        logger.debug(f'line_pos: {line_pos}')

        # Check if any events were filtered
        if upcoming_events:
            logger.info('Managed to parse events from urls')

            # Find out how much space the event times take
            time_width = int(max([canvas.get_text_width(
                events['begin'].format(self.time_format, locale=self.language))
                for events in upcoming_events]) + 10)
            logger.debug(f'time_width: {time_width}')

            # Calculate x-pos for time
            x_time = int(date_width/3)
            logger.debug(f'x-time: {x_time}')

            # Find out how much space is left for event titles
            event_width = im_width - time_width 
            logger.debug(f'width for events: {event_width}')

            # Calculate x-pos for event titles
            x_event = int(date_width/3) + time_width
            logger.debug(f'x-event: {x_event}')

            # Merge list of dates and list of events
            agenda_events += upcoming_events

            # Sort the combined list in chronological order of dates
            by_date = lambda event: event['begin']
            agenda_events.sort(key=by_date)

            # Delete more entries than can be displayed (max lines)
            del agenda_events[max_lines:]

            cursor = 0
            for _ in agenda_events:
                title = _['title']

                # Check if item is a date
                if 'end' not in _:
                    ImageDraw.Draw(canvas.image_colour).line(
                        (0, line_pos[cursor][1], im_width, line_pos[cursor][1]),
                        fill='black')

                    canvas.write(
                        xy=line_pos[cursor],
                        box_size=(date_width, line_height),
                        text=title,
                        alignment="left")

                    cursor += 1

                # Check if item is an event
                if 'end' in _:
                    time = _['begin'].format(self.time_format, locale=self.language)

                    # Check if event is all day, if not, add the time
                    if not ical.all_day(_):
                        canvas.write(
                            xy=(x_time, line_pos[cursor][1]),
                            box_size=(time_width, line_height),
                            text=time,
                            alignment="right")
                    else:
                        canvas.set_font(font=self.icon_font, font_size=self.fontsize)

                        canvas.write(
                            xy=(x_time, line_pos[cursor][1]),
                            box_size=(time_width, line_height),
                            text="\ue878",
                            alignment="right")

                    canvas.set_font(font=self.font, font_size=self.fontsize)
                    canvas.write(
                        xy=(x_event, line_pos[cursor][1]),
                        box_size=(event_width, line_height),
                        text=' • ' + title,
                        alignment="left")
                    cursor += 1

        # If no events were found, write only dates and lines
        else:
            logger.info('no events found')

            cursor = 0
            for _ in agenda_events:
                title = _['title']
                ImageDraw.Draw(canvas.image_colour).line(
                    (0, line_pos[cursor][1], im_width, line_pos[cursor][1]),
                    fill='black')

                canvas.write(
                    xy=line_pos[cursor],
                    box_size=(date_width, line_height),
                    text=title,
                    alignment="left")
                cursor += 1

        # return the images ready for the display
        return canvas.image_black, canvas.image_colour

__init__(config)

Initialize inkycal_agenda module

Source code in inkycal/modules/inkycal_agenda.py
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
def __init__(self, config):
    """Initialize inkycal_agenda module"""

    super().__init__(config)

    config = config['config']

    # Check if all required parameters are present
    for param in self.requires:
        if param not in config:
            raise Exception(f'config is missing {param}')

    # module specific parameters
    self.date_format = config['date_format']
    self.time_format = config['time_format']
    self.language = config['language']

    # Check if ical_files is an empty string
    if config['ical_urls'] and isinstance(config['ical_urls'], str):
        self.ical_urls = config['ical_urls'].split(',')
    else:
        self.ical_urls = config['ical_urls']

    # Check if ical_files is an empty string
    if config['ical_files'] and isinstance(config['ical_files'], str):
        self.ical_files = config['ical_files'].split(',')
    else:
        self.ical_files = config['ical_files']

    # Additional config
    self.timezone = get_system_tz()

    self.icon_font = FONTS.material_icons

    # give an OK message
    logger.debug(f'{__name__} loaded')

generate_image()

Generate image for this module

Source code in inkycal/modules/inkycal_agenda.py
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
def generate_image(self):
    """Generate image for this module"""

    # Define new image size with respect to padding
    im_width = int(self.width - (2 * self.padding_left))
    im_height = int(self.height - (2 * self.padding_top))
    im_size = im_width, im_height

    logger.debug(f'Image size: {im_size}')

    canvas = Canvas(im_size, self.font, self.fontsize)

    # Calculate the max number of lines that can fit on the image
    line_spacing = 1

    line_height = canvas.get_line_height() + line_spacing
    max_lines = im_height // line_height
    logger.debug(f'max lines: {max_lines}')

    # Create timeline for agenda
    now = arrow.now()
    today = now.floor('day')

    # Create a list of dates for the next days
    agenda_events = [
        {
            'begin': today.shift(days=+_),
            'title': today.shift(days=+_).format(
                self.date_format, locale=self.language)
        }
        for _ in range(max_lines)]

    # Load icalendar from config
    ical = iCalendar()

    if self.ical_urls:
        ical.load_url(self.ical_urls)

    if self.ical_files:
        ical.load_from_file(self.ical_files)

    # Load events from all icalendar in timerange
    upcoming_events = ical.get_events(today, agenda_events[-1]['begin'],
                                        self.timezone)

    # Sort events by beginning time
    ical.sort()
    # parser.show_events()

    # Set the width for date, time and event titles
    date_strings = [date['begin'].format(self.date_format, locale=self.language) for date in agenda_events]
    longest_date = max(date_strings, key=len)

    date_width = canvas.get_text_width(longest_date)
    logger.debug(f'date_width: {date_width}')

    # Calculate positions for each line
    line_pos = [(0, int(line * line_height)) for line in range(max_lines)]
    logger.debug(f'line_pos: {line_pos}')

    # Check if any events were filtered
    if upcoming_events:
        logger.info('Managed to parse events from urls')

        # Find out how much space the event times take
        time_width = int(max([canvas.get_text_width(
            events['begin'].format(self.time_format, locale=self.language))
            for events in upcoming_events]) + 10)
        logger.debug(f'time_width: {time_width}')

        # Calculate x-pos for time
        x_time = int(date_width/3)
        logger.debug(f'x-time: {x_time}')

        # Find out how much space is left for event titles
        event_width = im_width - time_width 
        logger.debug(f'width for events: {event_width}')

        # Calculate x-pos for event titles
        x_event = int(date_width/3) + time_width
        logger.debug(f'x-event: {x_event}')

        # Merge list of dates and list of events
        agenda_events += upcoming_events

        # Sort the combined list in chronological order of dates
        by_date = lambda event: event['begin']
        agenda_events.sort(key=by_date)

        # Delete more entries than can be displayed (max lines)
        del agenda_events[max_lines:]

        cursor = 0
        for _ in agenda_events:
            title = _['title']

            # Check if item is a date
            if 'end' not in _:
                ImageDraw.Draw(canvas.image_colour).line(
                    (0, line_pos[cursor][1], im_width, line_pos[cursor][1]),
                    fill='black')

                canvas.write(
                    xy=line_pos[cursor],
                    box_size=(date_width, line_height),
                    text=title,
                    alignment="left")

                cursor += 1

            # Check if item is an event
            if 'end' in _:
                time = _['begin'].format(self.time_format, locale=self.language)

                # Check if event is all day, if not, add the time
                if not ical.all_day(_):
                    canvas.write(
                        xy=(x_time, line_pos[cursor][1]),
                        box_size=(time_width, line_height),
                        text=time,
                        alignment="right")
                else:
                    canvas.set_font(font=self.icon_font, font_size=self.fontsize)

                    canvas.write(
                        xy=(x_time, line_pos[cursor][1]),
                        box_size=(time_width, line_height),
                        text="\ue878",
                        alignment="right")

                canvas.set_font(font=self.font, font_size=self.fontsize)
                canvas.write(
                    xy=(x_event, line_pos[cursor][1]),
                    box_size=(event_width, line_height),
                    text=' • ' + title,
                    alignment="left")
                cursor += 1

    # If no events were found, write only dates and lines
    else:
        logger.info('no events found')

        cursor = 0
        for _ in agenda_events:
            title = _['title']
            ImageDraw.Draw(canvas.image_colour).line(
                (0, line_pos[cursor][1], im_width, line_pos[cursor][1]),
                fill='black')

            canvas.write(
                xy=line_pos[cursor],
                box_size=(date_width, line_height),
                text=title,
                alignment="left")
            cursor += 1

    # return the images ready for the display
    return canvas.image_black, canvas.image_colour

Todoist

Inkycal Todoist Module Copyright by aceinnolab

Todoist

Bases: InkycalModule

Todoist api class parses todos from the todoist api.

Source code in inkycal/modules/inkycal_todoist.py
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
class Todoist(InkycalModule):
    """Todoist api class
    parses todos from the todoist api.
    """

    name = "Todoist API - show your todos from todoist"

    requires = {
        'api_key': {
            "label": "Please enter your Todoist API-key",
        },
    }

    optional = {
        'project_filter': {
            "label": "Show Todos only from following project (separated by a comma). Leave empty to show " +
                     "todos from all projects",
        },
        'show_priority': {
            "label": "Show priority indicators for tasks (P1, P2, P3)",
            "default": True
        }
    }

    def __init__(self, config):
        """Initialize inkycal_rss module"""

        super().__init__(config)

        config = config['config']

        # Check if all required parameters are present
        for param in self.requires:
            if param not in config:
                raise Exception(f'config is missing {param}')

        # module specific parameters
        self.api_key = config['api_key']

        # if project filter is set, initialize it
        if config['project_filter'] and isinstance(config['project_filter'], str):
            self.project_filter = config['project_filter'].split(',')
        else:
            self.project_filter = config['project_filter']

        # Priority display option
        self.show_priority = config.get('show_priority', True)

        self._api = TodoistAPI(config['api_key'])

        # Cache file path for storing last successful response
        self.cache_file = os.path.join(os.path.dirname(__file__), '..', '..', 'temp', 'todoist_cache.json')
        os.makedirs(os.path.dirname(self.cache_file), exist_ok=True)

        # give an OK message
        logger.debug(f'{__name__} loaded')

    def _validate(self):
        """Validate module-specific parameters"""
        if not isinstance(self.api_key, str):
            print('api_key has to be a string: "Yourtopsecretkey123" ')


    def _fetch_with_retry(
            self,
            fetch_func: Callable[[], Iterable[T]],
            *,
            max_retries: int = 3,
            retry_statuses: set[int] = {502, 503, 504},
    ) -> list[T]:
        """
        Fetch data with retry logic and exponential backoff.

        Retries on:
        - Connection errors
        - HTTP errors with retryable status codes

        Args:
            fetch_func: Callable returning an iterable of results.
            max_retries: Maximum number of attempts.
            retry_statuses: HTTP status codes that should trigger a retry.

        Returns:
            A list of fetched items.

        Raises:
            requests.exceptions.RequestException
            RuntimeError: If all retries are exhausted.
        """
        for attempt in range(1, max_retries + 1):
            try:
                return list(fetch_func())

            except requests.exceptions.HTTPError as exc:
                status = exc.response.status_code if exc.response else None
                if status not in retry_statuses or attempt == max_retries:
                    raise

            except requests.exceptions.ConnectionError:
                if attempt == max_retries:
                    raise

            delay = 2 ** (attempt - 1)
            logger.warning(
                "API request failed (attempt %d/%d), retrying in %ds...",
                attempt,
                max_retries,
                delay,
            )
            time.sleep(delay)

        raise RuntimeError("Max retries exceeded")

    def _save_cache(self, projects, tasks):
        """Save API response to cache file"""
        try:
            cache_data = {
                'timestamp': datetime.now().isoformat(),
                'projects': [{'id': p.id, 'name': p.name} for p in projects],
                'tasks': [{
                    'content': t.content,
                    'project_id': t.project_id,
                    'priority': t.priority,
                    'due': {'date': str(t.due.date)} if t.due else None
                } for t in tasks]
            }
            with open(self.cache_file, 'w') as f:
                json.dump(cache_data, f)
            logger.debug("Saved Todoist data to cache")
        except Exception as e:
            logger.warning(f"Failed to save cache: {e}")

    def _load_cache(self):
        """Load cached API response"""
        try:
            if os.path.exists(self.cache_file):
                with open(self.cache_file, 'r') as f:
                    return json.load(f)
        except Exception as e:
            logger.warning(f"Failed to load cache: {e}")
        return None

    def _create_error_image(self, im_size, error_msg=None, cached_data=None):
        """Create an error message image when API fails"""
        im_width, im_height = im_size
        canvas = Canvas(im_size=im_size, font=self.font, font_size=self.fontsize)

        # Display error message
        line_spacing = 1
        line_height = canvas.get_line_height() + line_spacing

        messages = []
        if error_msg:
            messages.append("Todoist temporarily unavailable")

        if cached_data and 'timestamp' in cached_data:
            timestamp = arrow.get(cached_data['timestamp']).format('D-MMM-YY HH:mm')
            messages.append(f"Showing cached data from:")
            messages.append(timestamp)
        else:
            messages.append("No cached data available")
            messages.append("Please check your connection")

        # Center the messages vertically
        total_height = len(messages) * line_height
        start_y = (im_height - total_height) // 2

        for i, msg in enumerate(messages):
            y_pos = start_y + (i * line_height)
            # First line in red (colour image), rest in black
            canvas.write(
                xy= (0, y_pos),
                box_size=(im_width, line_height),
                text=msg,
                colour="colour" if i == 0 else "black"
            )

        return canvas.image_black, canvas.image_colour

    def generate_image(self):
        """Generate image for this module"""

        # Define new image size with respect to padding
        im_width = int(self.width - (2 * self.padding_left))
        im_height = int(self.height - (2 * self.padding_top))
        im_size = im_width, im_height
        logger.debug(f'Image size: {im_size}')

        canvas = Canvas(im_size=im_size, font=self.font, font_size=self.fontsize)

        # Check if internet is available
        if not internet_available():
            logger.error("Network not reachable. Trying to use cached data.")
            cached_data = self._load_cache()
            if cached_data:
                # Process cached data below
                all_projects = [type('Project', (), p) for p in cached_data['projects']]
                all_active_tasks = [type('Task', (), {
                    'content': t['content'],
                    'project_id': t['project_id'],
                    'priority': t['priority'],
                    'due': type('Due', (), {'date': t['due']['date']}) if t['due'] else None
                }) for t in cached_data['tasks']]
            else:
                return self._create_error_image(im_size, "Network error", None)
        else:
            logger.info('Connection test passed')

            # Try to fetch fresh data from API
            try:
                all_projects = self._fetch_with_retry(self._api.get_projects)[0] or []
                all_tasks_api = self._fetch_with_retry(self._api.get_tasks) or []
                all_active_tasks = []
                for task_list in all_tasks_api:
                    all_active_tasks.extend(task_list)
                 # Save to cache on successful fetch
                self._save_cache(all_projects, all_active_tasks)
            except Exception as e:
                logger.error(f"Failed to fetch Todoist data: {e}")
                # Try to use cached data
                cached_data = self._load_cache()
                if cached_data:
                    logger.info("Using cached Todoist data")
                    all_projects = [type('Project', (), p) for p in cached_data['projects']]
                    all_active_tasks = [type('Task', (), {
                        'content': t['content'],
                        'project_id': t['project_id'],
                        'priority': t['priority'],
                        'due': type('Due', (), {'date': t['due']['date']}) if t['due'] else None
                    }) for t in cached_data['tasks']]
                else:
                    # No cached data available, show error
                    return self._create_error_image(im_size, str(e), None)

        # Set some parameters for formatting todos
        line_spacing = 1
        line_height = canvas.get_line_height() + line_spacing
        max_lines = im_height // line_height

        # Calculate padding from top so the lines look centralised
        spacing_top = int(im_height % line_height / 2)

        # Calculate line_positions
        line_positions = [
            (0, spacing_top + _ * line_height) for _ in range(max_lines)]

        # Process the fetched or cached data
        filtered_project_ids_and_names = {project.id: project.name for project in all_projects}

        logger.debug(f"all_projects: {all_projects}")

        # Filter entries in all_projects if filter was given
        if self.project_filter:
            filtered_projects = [project for project in all_projects if project.name in self.project_filter]
            filtered_project_ids_and_names = {project.id: project.name for project in filtered_projects}
            filtered_project_ids = [project for project in filtered_project_ids_and_names]
            logger.debug(f"filtered projects: {filtered_projects}")

            # If filter was activated and no project was found with that name,
            # raise an exception to avoid showing a blank image
            if not filtered_projects:
                logger.error('No project found from project filter!')
                logger.error('Please double check spellings in project_filter')
                raise Exception('No matching project found in filter. Please '
                                'double check spellings in project_filter or leave'
                                'empty')
            # filtered version of all active tasks
            all_active_tasks = [task for task in all_active_tasks if task.project_id in filtered_project_ids]

        # Simplify the tasks for faster processing
        simplified = []
        for task in all_active_tasks:
            # Format priority indicator using circle symbols
            priority_text = ""
            if self.show_priority and task.priority > 1:
                # Todoist uses reversed priority (4 = highest, 1 = lowest)
                if task.priority == 4:  # P1 - filled circle (red)
                    priority_text = "● "  # Filled circle for highest priority
                elif task.priority == 3:  # P2 - filled circle (black)
                    priority_text = "● "  # Filled circle for high priority
                elif task.priority == 2:  # P3 - empty circle (black)
                    priority_text = "○ "  # Empty circle for medium priority

            # Check if task is overdue
            # Parse date in local timezone to ensure correct comparison
            try:
                due_date = (
                    arrow.get(task.due.date).replace(tzinfo="local")
                    if task.due
                    else None
                )
            except Exception as e:
                logger.warning(
                    "Could not parse due date of task %r: %s",
                    task,
                    e,
                )
                continue
            today = arrow.now('local').floor('day')
            is_overdue = due_date and due_date < today if due_date else False

            # Format due date display
            if due_date:
                if due_date.floor('day') == today:
                    due_display = "TODAY"
                else:
                    due_display = due_date.format("D-MMM-YY")
            else:
                due_display = ""

            simplified.append({
                'name': task.content,
                'due': due_display,
                'due_date': due_date,
                'is_overdue': is_overdue,
                'priority': task.priority,
                'priority_text': priority_text,
                'project': filtered_project_ids_and_names[task.project_id] if task.project_id in filtered_project_ids_and_names else None
            })

        logger.debug(f'simplified: {simplified}')

        project_lengths = []
        due_lengths = []
        priority_lengths = []

        for task in simplified:
            if task["project"]:
                project_lengths.append(int(canvas.get_text_width(task['project']) * 1.1))
            if task["due"]:
                due_lengths.append(int(canvas.get_text_width(task['due']) * 1.1))
            if task["priority_text"]:
                priority_lengths.append(int(canvas.get_text_width(task['priority_text']) * 1.1))

        # Get maximum width of project names for selected font
        project_offset = int(max(project_lengths)) if project_lengths else 0

        # Get maximum width of project dues for selected font
        due_offset = int(max(due_lengths)) if due_lengths else 0

        # Get maximum width of priority indicators
        priority_offset = int(max(priority_lengths)) if priority_lengths else 0

        # create a dict with names of filtered groups
        groups = {group_name:[] for group_name in filtered_project_ids_and_names.values()}
        for task in simplified:
            group_of_current_task = task["project"]
            if group_of_current_task in groups:
                groups[group_of_current_task].append(task)

        # Sort tasks within each project group by due date first, then priority
        for project_name in groups:
            groups[project_name].sort(
                key=lambda task: (
                    task['due_date'] is None,  # Tasks with dates come first
                    task['due_date'] if task['due_date'] else arrow.get('9999-12-31'),  # Sort by date
                    -task['priority']  # Then by priority (higher priority first)
                )
            )

        logger.debug(f"grouped: {groups}")

        # Add the parsed todos on the image
        cursor = 0
        for name, todos in groups.items():
            if todos:
                for todo in todos:
                    if cursor < max_lines:
                        line_x, line_y = line_positions[cursor]

                        if todo['project']:
                            # Add todos project name
                            canvas.write(
                                xy=line_positions[cursor],
                                box_size=(project_offset, line_height),
                                text= todo['project'],
                                colour="colour",
                                alignment='left'
                            )

                        # Add todos due if not empty
                        if todo['due']:
                            # Show overdue dates in red, normal dates in black
                            canvas.write(
                                xy=(line_x + project_offset, line_y),
                                box_size=(due_offset, line_height),
                                text= todo['due'],
                                colour="colour" if todo.get('is_overdue', False) else "black",
                                alignment='left'
                            )

                        # Add priority indicator if present
                        if todo['priority_text']:
                            # P1 (priority 4) in red, P2 and P3 in black
                            canvas.write(
                                xy=(line_x + project_offset + due_offset, line_y),
                                box_size=(priority_offset, line_height),
                                text=todo['priority_text'],
                                colour="colour" if todo['priority'] == 4 else "black",
                                alignment='left'
                            )
                        if todo['name']:
                            # Add todos name
                            canvas.write(
                                xy=(line_x + project_offset + due_offset + priority_offset, line_y),
                                box_size=(im_width - project_offset - due_offset - priority_offset, line_height),
                                text=todo['name'],
                                alignment='left'
                            )
                        cursor += 1
                    else:
                        logger.error('More todos than available lines')
                        break

        # return the images ready for the display
        return canvas.image_black, canvas.image_colour

__init__(config)

Initialize inkycal_rss module

Source code in inkycal/modules/inkycal_todoist.py
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
def __init__(self, config):
    """Initialize inkycal_rss module"""

    super().__init__(config)

    config = config['config']

    # Check if all required parameters are present
    for param in self.requires:
        if param not in config:
            raise Exception(f'config is missing {param}')

    # module specific parameters
    self.api_key = config['api_key']

    # if project filter is set, initialize it
    if config['project_filter'] and isinstance(config['project_filter'], str):
        self.project_filter = config['project_filter'].split(',')
    else:
        self.project_filter = config['project_filter']

    # Priority display option
    self.show_priority = config.get('show_priority', True)

    self._api = TodoistAPI(config['api_key'])

    # Cache file path for storing last successful response
    self.cache_file = os.path.join(os.path.dirname(__file__), '..', '..', 'temp', 'todoist_cache.json')
    os.makedirs(os.path.dirname(self.cache_file), exist_ok=True)

    # give an OK message
    logger.debug(f'{__name__} loaded')

generate_image()

Generate image for this module

Source code in inkycal/modules/inkycal_todoist.py
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
def generate_image(self):
    """Generate image for this module"""

    # Define new image size with respect to padding
    im_width = int(self.width - (2 * self.padding_left))
    im_height = int(self.height - (2 * self.padding_top))
    im_size = im_width, im_height
    logger.debug(f'Image size: {im_size}')

    canvas = Canvas(im_size=im_size, font=self.font, font_size=self.fontsize)

    # Check if internet is available
    if not internet_available():
        logger.error("Network not reachable. Trying to use cached data.")
        cached_data = self._load_cache()
        if cached_data:
            # Process cached data below
            all_projects = [type('Project', (), p) for p in cached_data['projects']]
            all_active_tasks = [type('Task', (), {
                'content': t['content'],
                'project_id': t['project_id'],
                'priority': t['priority'],
                'due': type('Due', (), {'date': t['due']['date']}) if t['due'] else None
            }) for t in cached_data['tasks']]
        else:
            return self._create_error_image(im_size, "Network error", None)
    else:
        logger.info('Connection test passed')

        # Try to fetch fresh data from API
        try:
            all_projects = self._fetch_with_retry(self._api.get_projects)[0] or []
            all_tasks_api = self._fetch_with_retry(self._api.get_tasks) or []
            all_active_tasks = []
            for task_list in all_tasks_api:
                all_active_tasks.extend(task_list)
             # Save to cache on successful fetch
            self._save_cache(all_projects, all_active_tasks)
        except Exception as e:
            logger.error(f"Failed to fetch Todoist data: {e}")
            # Try to use cached data
            cached_data = self._load_cache()
            if cached_data:
                logger.info("Using cached Todoist data")
                all_projects = [type('Project', (), p) for p in cached_data['projects']]
                all_active_tasks = [type('Task', (), {
                    'content': t['content'],
                    'project_id': t['project_id'],
                    'priority': t['priority'],
                    'due': type('Due', (), {'date': t['due']['date']}) if t['due'] else None
                }) for t in cached_data['tasks']]
            else:
                # No cached data available, show error
                return self._create_error_image(im_size, str(e), None)

    # Set some parameters for formatting todos
    line_spacing = 1
    line_height = canvas.get_line_height() + line_spacing
    max_lines = im_height // line_height

    # Calculate padding from top so the lines look centralised
    spacing_top = int(im_height % line_height / 2)

    # Calculate line_positions
    line_positions = [
        (0, spacing_top + _ * line_height) for _ in range(max_lines)]

    # Process the fetched or cached data
    filtered_project_ids_and_names = {project.id: project.name for project in all_projects}

    logger.debug(f"all_projects: {all_projects}")

    # Filter entries in all_projects if filter was given
    if self.project_filter:
        filtered_projects = [project for project in all_projects if project.name in self.project_filter]
        filtered_project_ids_and_names = {project.id: project.name for project in filtered_projects}
        filtered_project_ids = [project for project in filtered_project_ids_and_names]
        logger.debug(f"filtered projects: {filtered_projects}")

        # If filter was activated and no project was found with that name,
        # raise an exception to avoid showing a blank image
        if not filtered_projects:
            logger.error('No project found from project filter!')
            logger.error('Please double check spellings in project_filter')
            raise Exception('No matching project found in filter. Please '
                            'double check spellings in project_filter or leave'
                            'empty')
        # filtered version of all active tasks
        all_active_tasks = [task for task in all_active_tasks if task.project_id in filtered_project_ids]

    # Simplify the tasks for faster processing
    simplified = []
    for task in all_active_tasks:
        # Format priority indicator using circle symbols
        priority_text = ""
        if self.show_priority and task.priority > 1:
            # Todoist uses reversed priority (4 = highest, 1 = lowest)
            if task.priority == 4:  # P1 - filled circle (red)
                priority_text = "● "  # Filled circle for highest priority
            elif task.priority == 3:  # P2 - filled circle (black)
                priority_text = "● "  # Filled circle for high priority
            elif task.priority == 2:  # P3 - empty circle (black)
                priority_text = "○ "  # Empty circle for medium priority

        # Check if task is overdue
        # Parse date in local timezone to ensure correct comparison
        try:
            due_date = (
                arrow.get(task.due.date).replace(tzinfo="local")
                if task.due
                else None
            )
        except Exception as e:
            logger.warning(
                "Could not parse due date of task %r: %s",
                task,
                e,
            )
            continue
        today = arrow.now('local').floor('day')
        is_overdue = due_date and due_date < today if due_date else False

        # Format due date display
        if due_date:
            if due_date.floor('day') == today:
                due_display = "TODAY"
            else:
                due_display = due_date.format("D-MMM-YY")
        else:
            due_display = ""

        simplified.append({
            'name': task.content,
            'due': due_display,
            'due_date': due_date,
            'is_overdue': is_overdue,
            'priority': task.priority,
            'priority_text': priority_text,
            'project': filtered_project_ids_and_names[task.project_id] if task.project_id in filtered_project_ids_and_names else None
        })

    logger.debug(f'simplified: {simplified}')

    project_lengths = []
    due_lengths = []
    priority_lengths = []

    for task in simplified:
        if task["project"]:
            project_lengths.append(int(canvas.get_text_width(task['project']) * 1.1))
        if task["due"]:
            due_lengths.append(int(canvas.get_text_width(task['due']) * 1.1))
        if task["priority_text"]:
            priority_lengths.append(int(canvas.get_text_width(task['priority_text']) * 1.1))

    # Get maximum width of project names for selected font
    project_offset = int(max(project_lengths)) if project_lengths else 0

    # Get maximum width of project dues for selected font
    due_offset = int(max(due_lengths)) if due_lengths else 0

    # Get maximum width of priority indicators
    priority_offset = int(max(priority_lengths)) if priority_lengths else 0

    # create a dict with names of filtered groups
    groups = {group_name:[] for group_name in filtered_project_ids_and_names.values()}
    for task in simplified:
        group_of_current_task = task["project"]
        if group_of_current_task in groups:
            groups[group_of_current_task].append(task)

    # Sort tasks within each project group by due date first, then priority
    for project_name in groups:
        groups[project_name].sort(
            key=lambda task: (
                task['due_date'] is None,  # Tasks with dates come first
                task['due_date'] if task['due_date'] else arrow.get('9999-12-31'),  # Sort by date
                -task['priority']  # Then by priority (higher priority first)
            )
        )

    logger.debug(f"grouped: {groups}")

    # Add the parsed todos on the image
    cursor = 0
    for name, todos in groups.items():
        if todos:
            for todo in todos:
                if cursor < max_lines:
                    line_x, line_y = line_positions[cursor]

                    if todo['project']:
                        # Add todos project name
                        canvas.write(
                            xy=line_positions[cursor],
                            box_size=(project_offset, line_height),
                            text= todo['project'],
                            colour="colour",
                            alignment='left'
                        )

                    # Add todos due if not empty
                    if todo['due']:
                        # Show overdue dates in red, normal dates in black
                        canvas.write(
                            xy=(line_x + project_offset, line_y),
                            box_size=(due_offset, line_height),
                            text= todo['due'],
                            colour="colour" if todo.get('is_overdue', False) else "black",
                            alignment='left'
                        )

                    # Add priority indicator if present
                    if todo['priority_text']:
                        # P1 (priority 4) in red, P2 and P3 in black
                        canvas.write(
                            xy=(line_x + project_offset + due_offset, line_y),
                            box_size=(priority_offset, line_height),
                            text=todo['priority_text'],
                            colour="colour" if todo['priority'] == 4 else "black",
                            alignment='left'
                        )
                    if todo['name']:
                        # Add todos name
                        canvas.write(
                            xy=(line_x + project_offset + due_offset + priority_offset, line_y),
                            box_size=(im_width - project_offset - due_offset - priority_offset, line_height),
                            text=todo['name'],
                            alignment='left'
                        )
                    cursor += 1
                else:
                    logger.error('More todos than available lines')
                    break

    # return the images ready for the display
    return canvas.image_black, canvas.image_colour

Webshot

Webshot module for Inkycal by https://github.com/worstface

Webshot

Bases: InkycalModule

Source code in inkycal/modules/inkycal_webshot.py
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
class Webshot(InkycalModule):
    name = "Webshot - Displays screenshots of webpages"

    # required parameters
    requires = {

        "url": {
            "label": "Please enter the url",
        },
        "palette": {
            "label": "Which color palette should be used for the webshots?",
            "options": ["bw", "bwr", "bwy"]
        }
    }

    optional = {

        "crop_x": {
            "label": "Please enter the crop x-position",
        },
        "crop_y": {
            "label": "Please enter the crop y-position",
        },
        "crop_w": {
            "label": "Please enter the crop width",
        },
        "crop_h": {
            "label": "Please enter the crop height",
        },
        "rotation": {
            "label": "Please enter the rotation. Must be either 0, 90, 180 or 270",
        },
    }

    def __init__(self, config):

        super().__init__(config)

        config = config['config']

        self.url = config['url']
        self.palette = config['palette']

        if "crop_h" in config and isinstance(config["crop_h"], str):
            self.crop_h = int(config["crop_h"])
        else:
            self.crop_h = 2000

        if "crop_w" in config and isinstance(config["crop_w"], str):
            self.crop_w = int(config["crop_w"])
        else:
            self.crop_w = 2000

        if "crop_x" in config and isinstance(config["crop_x"], str):
            self.crop_x = int(config["crop_x"])
        else:
            self.crop_x = 0

        if "crop_y" in config and isinstance(config["crop_y"], str):
            self.crop_y = int(config["crop_y"])
        else:
            self.crop_y = 0

        self.rotation = 0
        if "rotation" in config:
            self.rotation = int(config["rotation"])
            if self.rotation not in [0, 90, 180, 270]:
                raise Exception("Rotation must be either 0, 90, 180 or 270")

        # give an OK message
        logger.debug(f'Inkycal webshot loaded')

    def generate_image(self):
        """Generate image for this module"""

        # Create tmp path
        tmpFolder = settings.TEMPORARY_FOLDER

        if not os.path.exists(tmpFolder):
            print(f"Creating tmp directory {tmpFolder}")
            os.mkdir(tmpFolder)

        # Define new image size with respect to padding
        im_width = int(self.width - (2 * self.padding_left))
        im_height = int(self.height - (2 * self.padding_top))
        if self.rotation in (90, 270):
            im_width, im_height = im_height, im_width
        im_size = im_width, im_height
        logger.debug('image size: {} x {} px'.format(im_width, im_height))

        # Create an image for black pixels and one for coloured pixels (required)
        im_black = Image.new('RGB', size=im_size, color='white')
        im_colour = Image.new('RGB', size=im_size, color='white')

        # Check if internet is available
        if internet_available():
            logger.info('Connection test passed')
        else:
            logger.error("Network not reachable. Please check your connection.")
            raise Exception('Network could not be reached :/')

        logger.info(
            f'preparing webshot from {self.url}... cropH{self.crop_h} cropW{self.crop_w} cropX{self.crop_x} cropY{self.crop_y}')

        shot = WebShot(size=(im_height, im_width))

        shot.params = {
            "--crop-x": self.crop_x,
            "--crop-y": self.crop_y,
            "--crop-w": self.crop_w,
            "--crop-h": self.crop_h,
        }

        logger.info(f'getting webshot from {self.url}...')

        try:
            shot.create_pic(url=self.url, output=f"{tmpFolder}/webshot.png")
        except:
            print(traceback.format_exc())
            print("If you have not already installed wkhtmltopdf, please use: sudo apt-get install wkhtmltopdf. See here for more details: https://github.com/1Danish-00/htmlwebshot/")
            raise Exception('Could not get webshot :/')


        logger.info(f'got webshot...')

        webshotSpaceBlack = Image.new('RGBA', (im_width, im_height), (255, 255, 255, 255))
        webshotSpaceColour = Image.new('RGBA', (im_width, im_height), (255, 255, 255, 255))

        im = Images()
        im.load(f'{tmpFolder}/webshot.png')
        im.remove_alpha()

        imageAspectRatio = im_width / im_height
        webshotAspectRatio = im.image.width / im.image.height

        if webshotAspectRatio > imageAspectRatio:
            imageScale = im_width / im.image.width
        else:
            imageScale = im_height / im.image.height

        webshotHeight = int(im.image.height * imageScale)

        im.resize(width=int(im.image.width * imageScale), height=webshotHeight)

        im_webshot_black, im_webshot_colour = image_to_palette(im.image.convert("RGB"), self.palette)

        webshotCenterPosY = int((im_height / 2) - (im.image.height / 2))

        centerPosX = int((im_width / 2) - (im.image.width / 2))


        if self.rotation != 0:
            webshotSpaceBlack.paste(im_webshot_black, (centerPosX, webshotCenterPosY))
            im_black.paste(webshotSpaceBlack)
            im_black = im_black.rotate(self.rotation, expand=True)

            webshotSpaceColour.paste(im_webshot_colour, (centerPosX, webshotCenterPosY))
            im_colour.paste(webshotSpaceColour)
            im_colour = im_colour.rotate(self.rotation, expand=True)
        else:
            webshotSpaceBlack.paste(im_webshot_black, (centerPosX, webshotCenterPosY))
            im_black.paste(webshotSpaceBlack)

            webshotSpaceColour.paste(im_webshot_colour, (centerPosX, webshotCenterPosY))
            im_colour.paste(webshotSpaceColour)

        im.clear()
        logger.info(f'added webshot image')

        # Save image of black and colour channel in image-folder
        return im_black, im_colour

generate_image()

Generate image for this module

Source code in inkycal/modules/inkycal_webshot.py
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
def generate_image(self):
    """Generate image for this module"""

    # Create tmp path
    tmpFolder = settings.TEMPORARY_FOLDER

    if not os.path.exists(tmpFolder):
        print(f"Creating tmp directory {tmpFolder}")
        os.mkdir(tmpFolder)

    # Define new image size with respect to padding
    im_width = int(self.width - (2 * self.padding_left))
    im_height = int(self.height - (2 * self.padding_top))
    if self.rotation in (90, 270):
        im_width, im_height = im_height, im_width
    im_size = im_width, im_height
    logger.debug('image size: {} x {} px'.format(im_width, im_height))

    # Create an image for black pixels and one for coloured pixels (required)
    im_black = Image.new('RGB', size=im_size, color='white')
    im_colour = Image.new('RGB', size=im_size, color='white')

    # Check if internet is available
    if internet_available():
        logger.info('Connection test passed')
    else:
        logger.error("Network not reachable. Please check your connection.")
        raise Exception('Network could not be reached :/')

    logger.info(
        f'preparing webshot from {self.url}... cropH{self.crop_h} cropW{self.crop_w} cropX{self.crop_x} cropY{self.crop_y}')

    shot = WebShot(size=(im_height, im_width))

    shot.params = {
        "--crop-x": self.crop_x,
        "--crop-y": self.crop_y,
        "--crop-w": self.crop_w,
        "--crop-h": self.crop_h,
    }

    logger.info(f'getting webshot from {self.url}...')

    try:
        shot.create_pic(url=self.url, output=f"{tmpFolder}/webshot.png")
    except:
        print(traceback.format_exc())
        print("If you have not already installed wkhtmltopdf, please use: sudo apt-get install wkhtmltopdf. See here for more details: https://github.com/1Danish-00/htmlwebshot/")
        raise Exception('Could not get webshot :/')


    logger.info(f'got webshot...')

    webshotSpaceBlack = Image.new('RGBA', (im_width, im_height), (255, 255, 255, 255))
    webshotSpaceColour = Image.new('RGBA', (im_width, im_height), (255, 255, 255, 255))

    im = Images()
    im.load(f'{tmpFolder}/webshot.png')
    im.remove_alpha()

    imageAspectRatio = im_width / im_height
    webshotAspectRatio = im.image.width / im.image.height

    if webshotAspectRatio > imageAspectRatio:
        imageScale = im_width / im.image.width
    else:
        imageScale = im_height / im.image.height

    webshotHeight = int(im.image.height * imageScale)

    im.resize(width=int(im.image.width * imageScale), height=webshotHeight)

    im_webshot_black, im_webshot_colour = image_to_palette(im.image.convert("RGB"), self.palette)

    webshotCenterPosY = int((im_height / 2) - (im.image.height / 2))

    centerPosX = int((im_width / 2) - (im.image.width / 2))


    if self.rotation != 0:
        webshotSpaceBlack.paste(im_webshot_black, (centerPosX, webshotCenterPosY))
        im_black.paste(webshotSpaceBlack)
        im_black = im_black.rotate(self.rotation, expand=True)

        webshotSpaceColour.paste(im_webshot_colour, (centerPosX, webshotCenterPosY))
        im_colour.paste(webshotSpaceColour)
        im_colour = im_colour.rotate(self.rotation, expand=True)
    else:
        webshotSpaceBlack.paste(im_webshot_black, (centerPosX, webshotCenterPosY))
        im_black.paste(webshotSpaceBlack)

        webshotSpaceColour.paste(im_webshot_colour, (centerPosX, webshotCenterPosY))
        im_colour.paste(webshotSpaceColour)

    im.clear()
    logger.info(f'added webshot image')

    # Save image of black and colour channel in image-folder
    return im_black, im_colour

Slideshow

Inkycal Slideshow Module Copyright by aceinnolab

Slideshow

Bases: InkycalModule

Cycles through images in a local image folder

Source code in inkycal/modules/inkycal_slideshow.py
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
class Slideshow(InkycalModule):
    """Cycles through images in a local image folder"""
    name = "Slideshow - cycle through images from a local folder"

    requires = {

        "path": {
            "label": "Path to a local folder, e.g. /home/pi/Desktop/images. "
                     "Only PNG and JPG/JPEG images are used for the slideshow."
        },

        "palette": {
            "label": "Which palette should be used for converting images?",
            "options": ["bw", "bwr", "bwy"]
        }

    }

    optional = {

        "autoflip": {
            "label": "Should the image be flipped automatically? Default is False",
            "options": [False, True]
        },

        "orientation": {
            "label": "Please select the desired orientation",
            "options": ["vertical", "horizontal"]
        }
    }

    def __init__(self, config):
        """Initialize module"""

        super().__init__(config)

        config = config['config']

        # required parameters
        for param in self.requires:
            if param not in config:
                raise Exception(f'config is missing {param}')

        # optional parameters
        self.path = config['path']
        self.palette = config['palette']
        self.autoflip = config['autoflip']
        self.orientation = config['orientation']

        # Get the full path of all png/jpg/jpeg images in the given folder
        all_files = glob.glob(f'{self.path}/*')
        self.images = [i for i in all_files if i.split('.')[-1].lower() in ('jpg', 'jpeg', 'png')]

        if not self.images:
            logger.error('No images found in the given folder, please double check your path!')
            raise Exception('No images found in the given folder path :/')

        self.cache = JSONCache('inkycal_slideshow')
        self.cache_data = self.cache.read()

        # set a 'first run' signal
        self._first_run = True

        # give an OK message
        logger.debug(f'{__name__} loaded')

    def generate_image(self):
        """Generate image for this module"""

        # Define new image size with respect to padding
        im_width = int(self.width - (2 * self.padding_left))
        im_height = int(self.height - (2 * self.padding_top))
        im_size = im_width, im_height

        logger.debug(f'Image size: {im_size}')

        # rotates list items by 1 index
        def rotate(list: list):
            return list[1:] + list[:1]

        # Switch to the next image if this is not the first run
        if self._first_run:
            self._first_run = False
            self.cache_data["current_index"] = 0
        else:
            self.images = rotate(self.images)
            self.cache_data["current_index"] = (self.cache_data["current_index"] + 1) % len(self.images)

        # initialize custom image class
        im = Images()

        # temporary print method, prints current filename
        print(f'slideshow - current image name: {self.images[0].split("/")[-1]}')

        # use the image at the first index
        im.load(self.images[0])

        # Remove background if present
        im.remove_alpha()

        # if auto-flip was enabled, flip the image
        if self.autoflip:
            im.autoflip(self.orientation)

        # resize the image so it can fit on the epaper
        im.resize(width=im_width, height=im_height)

        # convert images according to specified palette
        im_black, im_colour = image_to_palette(im.image.convert("RGB"), self.palette)

        # with the images now send, clear the current image
        im.clear()

        self.cache.write(self.cache_data)

        # return images
        return im_black, im_colour

__init__(config)

Initialize module

Source code in inkycal/modules/inkycal_slideshow.py
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
def __init__(self, config):
    """Initialize module"""

    super().__init__(config)

    config = config['config']

    # required parameters
    for param in self.requires:
        if param not in config:
            raise Exception(f'config is missing {param}')

    # optional parameters
    self.path = config['path']
    self.palette = config['palette']
    self.autoflip = config['autoflip']
    self.orientation = config['orientation']

    # Get the full path of all png/jpg/jpeg images in the given folder
    all_files = glob.glob(f'{self.path}/*')
    self.images = [i for i in all_files if i.split('.')[-1].lower() in ('jpg', 'jpeg', 'png')]

    if not self.images:
        logger.error('No images found in the given folder, please double check your path!')
        raise Exception('No images found in the given folder path :/')

    self.cache = JSONCache('inkycal_slideshow')
    self.cache_data = self.cache.read()

    # set a 'first run' signal
    self._first_run = True

    # give an OK message
    logger.debug(f'{__name__} loaded')

generate_image()

Generate image for this module

Source code in inkycal/modules/inkycal_slideshow.py
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
def generate_image(self):
    """Generate image for this module"""

    # Define new image size with respect to padding
    im_width = int(self.width - (2 * self.padding_left))
    im_height = int(self.height - (2 * self.padding_top))
    im_size = im_width, im_height

    logger.debug(f'Image size: {im_size}')

    # rotates list items by 1 index
    def rotate(list: list):
        return list[1:] + list[:1]

    # Switch to the next image if this is not the first run
    if self._first_run:
        self._first_run = False
        self.cache_data["current_index"] = 0
    else:
        self.images = rotate(self.images)
        self.cache_data["current_index"] = (self.cache_data["current_index"] + 1) % len(self.images)

    # initialize custom image class
    im = Images()

    # temporary print method, prints current filename
    print(f'slideshow - current image name: {self.images[0].split("/")[-1]}')

    # use the image at the first index
    im.load(self.images[0])

    # Remove background if present
    im.remove_alpha()

    # if auto-flip was enabled, flip the image
    if self.autoflip:
        im.autoflip(self.orientation)

    # resize the image so it can fit on the epaper
    im.resize(width=im_width, height=im_height)

    # convert images according to specified palette
    im_black, im_colour = image_to_palette(im.image.convert("RGB"), self.palette)

    # with the images now send, clear the current image
    im.clear()

    self.cache.write(self.cache_data)

    # return images
    return im_black, im_colour

Server Module

Inkycal-server module for Inkycal Project by Aterju (https://inkycal.robertsirre.nl/) Copyright by aceinnolab

Inkyserver

Bases: InkycalModule

Displays an image from URL or local path

Source code in inkycal/modules/inkycal_server.py
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
class Inkyserver(InkycalModule):
    """Displays an image from URL or local path
    """

    name = "Inykcal Server - fetches an image from Inkycal-server - (https://inkycal.robertsirre.nl/)"

    requires = {

        "path": {
            "label": "Which URL should be used to get the image?"
        },

        "palette": {
            "label": "Which palette should be used to convert the images?",
            "options": ['bw', 'bwr', 'bwy']
        }

    }

    optional = {

        "path_body": {
            "label": "Send this data to the server via POST. Use a comma to "
                     "separate multiple items",
        },
        "dither": {
            "label": "Dither images before sending to E-Paper? Default is False.",
            "options": [False, True],
        }

    }

    def __init__(self, config):
        """Initialize module"""

        super().__init__(config)

        config = config['config']

        # required parameters
        for param in self.requires:
            if param not in config:
                raise Exception(f'config is missing {param}')

        # optional parameters
        self.path = config['path']
        self.palette = config['palette']
        self.dither = config['dither']

        # convert path_body to list, if not already
        if config['path_body'] and isinstance(config['path_body'], str):
            self.path_body = config['path_body'].split(',')
        else:
            self.path_body = config['path_body']

        # give an OK message
        logger.debug(f'{__name__} loaded')

    def generate_image(self):
        """Generate image for this module"""

        # Define new image size with respect to padding
        im_width = int(self.width - (2 * self.padding_left))
        im_height = int(self.height - (2 * self.padding_top))
        im_size = im_width, im_height

        logger.info(f'Image size: {im_size}')

        # replace width and height of url
        print(self.path)
        self.path = self.path.format(width=im_width, height=im_height)
        print(f"modified path: {self.path}")

        # initialize custom image class
        im = Images()

        # when no path_body is provided, use plain GET
        if not self.path_body:

            # use the image at the first index
            im.load(self.path)

        # else use POST request
        else:
            # Get the response image
            response = Image.open(requests.post(
                self.path, json=self.path_body, stream=True).raw)

            # initialize custom image class with response
            im = Images(response)

        # resize the image to respect padding
        im.resize(width=im_width, height=im_height)

        # convert image to given palette
        im_black, im_colour = im.to_palette(self.palette, dither=self.dither)

        # with the images now send, clear the current image
        im.clear()

        # return images
        return im_black, im_colour

__init__(config)

Initialize module

Source code in inkycal/modules/inkycal_server.py
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
def __init__(self, config):
    """Initialize module"""

    super().__init__(config)

    config = config['config']

    # required parameters
    for param in self.requires:
        if param not in config:
            raise Exception(f'config is missing {param}')

    # optional parameters
    self.path = config['path']
    self.palette = config['palette']
    self.dither = config['dither']

    # convert path_body to list, if not already
    if config['path_body'] and isinstance(config['path_body'], str):
        self.path_body = config['path_body'].split(',')
    else:
        self.path_body = config['path_body']

    # give an OK message
    logger.debug(f'{__name__} loaded')

generate_image()

Generate image for this module

Source code in inkycal/modules/inkycal_server.py
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
def generate_image(self):
    """Generate image for this module"""

    # Define new image size with respect to padding
    im_width = int(self.width - (2 * self.padding_left))
    im_height = int(self.height - (2 * self.padding_top))
    im_size = im_width, im_height

    logger.info(f'Image size: {im_size}')

    # replace width and height of url
    print(self.path)
    self.path = self.path.format(width=im_width, height=im_height)
    print(f"modified path: {self.path}")

    # initialize custom image class
    im = Images()

    # when no path_body is provided, use plain GET
    if not self.path_body:

        # use the image at the first index
        im.load(self.path)

    # else use POST request
    else:
        # Get the response image
        response = Image.open(requests.post(
            self.path, json=self.path_body, stream=True).raw)

        # initialize custom image class with response
        im = Images(response)

    # resize the image to respect padding
    im.resize(width=im_width, height=im_height)

    # convert image to given palette
    im_black, im_colour = im.to_palette(self.palette, dither=self.dither)

    # with the images now send, clear the current image
    im.clear()

    # return images
    return im_black, im_colour

XKCD

Inkycal XKCD module by https://github.com/worstface

Xkcd

Bases: InkycalModule

Source code in inkycal/modules/inkycal_xkcd.py
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
class Xkcd(InkycalModule):
    name = "xkcd - Displays comics from xkcd.com by Randall Munroe"

    # required parameters
    requires = {

        "mode": {
            "label": "Please select the mode",
            "options": ["latest", "random"],
            "default": "latest"
        },
        "palette": {
            "label": "Which color palette should be used for the comic Image2?",
            "options": ["bw", "bwr", "bwy"]
        },
        "alt": {
            "label": "Would you like to add the alt text below the comic? If XKCD is not the only module you are showing, I recommend setting this to 'no'",
            "options": ["yes", "no"],
            "default": "no"
        },
        "filter": {
            "label": "Would you like to add a scaling filter? If the is far too big to be shown in the space you've allotted for it, the module will try to find another image for you. This only applies in random mode. If XKCD is not the only module you are showing, I recommend setting this to 'no'.",
            "options": ["yes", "no"],
            "default": "no"
        }
    }

    def __init__(self, config):

        super().__init__(config)

        config = config['config']

        self.mode = config['mode']
        self.palette = config['palette']
        self.alt = config['alt']
        self.scale_filter = config['filter']

        # give an OK message
        logger.debug(f'Inkycal XKCD loaded')

    def generate_image(self):
        """Generate image for this module"""

        # Create tmp path
        tmpPath = settings.TEMPORARY_FOLDER

        if not os.path.exists(tmpPath):
            os.mkdir(tmpPath)

        # Define new image size with respect to padding
        im_width = int(self.width - (2 * self.padding_left))
        im_height = int(self.height - (2 * self.padding_top))
        im_size = im_width, im_height
        logger.debug(f'image size: {im_width} x {im_height} px')

        canvas = Canvas(im_size, font=self.font, font_size=self.fontsize)

        # Check if internet is available
        if internet_available():
            logger.info('Connection test passed')
        else:
            logger.error("Network not reachable. Please check your connection.")
            raise Exception('Network could not be reached :/')

        # Set some parameters for formatting feeds
        line_spacing = 1
        line_height = canvas.get_line_height() + line_spacing
        line_width = im_width
        max_lines = im_height // (line_height + line_spacing)

        logger.debug(f"max_lines: {max_lines}")

        # Calculate padding from top so the lines look centralised
        spacing_top = int(im_height % line_height / 2)

        # Calculate line_positions
        line_positions = [(0, spacing_top + _ * line_height) for _ in range(max_lines)]

        logger.debug(f'line positions: {line_positions}')

        logger.info(f'getting xkcd comic...')

        if self.mode == 'random':
            if self.scale_filter == 'no':
                xkcdComic = xkcd.getRandomComic()
                xkcdComic.download(output=tmpPath, outputFile='xkcdComic.png')
            else:
                perc = (2.1, 0.4)
                url = "test variable, not a real comic"
                while max(perc) > 1.75:
                    print("looking for another comic, old comic was: ", perc, url)
                    xkcdComic = xkcd.getRandomComic()
                    xkcdComic.download(output=tmpPath, outputFile='xkcdComic.png')
                    actual_size = Image.open(tmpPath + '/xkcdComic.png').size
                    perc = (actual_size[0] / im_width, actual_size[1] / im_height)
                    url = xkcdComic.getImageLink()
                print("found one! perc: ", perc, url)
        else:
            xkcdComic = xkcd.getLatestComic()
            xkcdComic.download(output=tmpPath, outputFile='xkcdComic.png')

        logger.info(f'got xkcd comic...')
        title_lines = []
        title_lines.append(xkcdComic.getTitle())

        altOffset = int(line_height * 1)

        if self.alt == "yes":
            alt_text = xkcdComic.getAltText()  # get the alt text, too (I break it up into multiple lines later on)

            # break up the alt text into lines
            alt_lines = []
            current_line = ""
            for _ in alt_text.split(" "):
                # this breaks up the alt_text into words and creates each line by adding
                # one word at a time until the line is longer than the width of the module
                # then it appends the line to the alt_lines array and starts testing a new line
                # with the next word
                if canvas.get_text_width(current_line + _ + " ") < im_width:
                    current_line = current_line + _ + " "
                else:
                    alt_lines.append(current_line)
                    current_line = _ + " "
            alt_lines.append(
                current_line)  # this adds the last line to the array (or the only line, if the alt text is really short)
            altHeight = int(line_height * len(alt_lines)) + altOffset
        else:
            altHeight = 0  # this is added so that I don't need to add more "if alt is yes" conditionals when centering below. Now the centering code will work regardless of whether they want alttext or not

        comicSpaceBlack = Image.new('RGBA', (im_width, im_height), (255, 255, 255, 255))
        comicSpaceColour = Image.new('RGBA', (im_width, im_height), (255, 255, 255, 255))

        im = Image2()
        im.load(f"{tmpPath}/xkcdComic.png")
        im.remove_alpha()

        imageAspectRatio = im_width / im_height
        comicAspectRatio = im.image.width / im.image.height

        if comicAspectRatio > imageAspectRatio:
            imageScale = im_width / im.image.width
        else:
            imageScale = im_height / im.image.height

        comicHeight = int(im.image.height * imageScale)

        headerHeight = int(line_height * 3 / 2)

        if comicHeight + (headerHeight + altHeight) > im_height:
            comicHeight -= (headerHeight + altHeight)

        im.resize(width=int(im.image.width * imageScale), height=comicHeight)

        im_comic_black, im_comic_colour = image_to_palette(im.image.convert("RGB"), self.palette)

        headerCenterPosY = int((im_height / 2) - ((im.image.height + headerHeight + altHeight) / 2))
        comicCenterPosY = int((im_height / 2) - ((im.image.height + headerHeight + altHeight) / 2) + headerHeight)
        altCenterPosY = int(
            (im_height / 2) - ((im.image.height + headerHeight + altHeight) / 2) + headerHeight + im.image.height)

        centerPosX = int((im_width / 2) - (im.image.width / 2))

        comicSpaceBlack.paste(im_comic_black, (centerPosX, comicCenterPosY))
        canvas.image_black.paste(comicSpaceBlack)

        comicSpaceColour.paste(im_comic_colour, (centerPosX, comicCenterPosY))
        canvas.image_colour.paste(comicSpaceColour)

        im.clear()
        logger.info(f'added comic image')

        # Write the title on the black image
        canvas.write(
            xy=(0, headerCenterPosY),
            box_size=(line_width, line_height),
            text=title_lines[0]
        )

        if self.alt == "yes":
            # write alt_text
            for _ in range(len(alt_lines)):
                canvas.write(
                    xy=(0, altCenterPosY + _ * line_height + altOffset),
                    box_size=(line_width, line_height),
                    text=alt_lines[_],
                    alignment='left'
                )
        # Save image of black and colour channel in image-folder
        return canvas.image_black, canvas.image_colour

generate_image()

Generate image for this module

Source code in inkycal/modules/inkycal_xkcd.py
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
def generate_image(self):
    """Generate image for this module"""

    # Create tmp path
    tmpPath = settings.TEMPORARY_FOLDER

    if not os.path.exists(tmpPath):
        os.mkdir(tmpPath)

    # Define new image size with respect to padding
    im_width = int(self.width - (2 * self.padding_left))
    im_height = int(self.height - (2 * self.padding_top))
    im_size = im_width, im_height
    logger.debug(f'image size: {im_width} x {im_height} px')

    canvas = Canvas(im_size, font=self.font, font_size=self.fontsize)

    # Check if internet is available
    if internet_available():
        logger.info('Connection test passed')
    else:
        logger.error("Network not reachable. Please check your connection.")
        raise Exception('Network could not be reached :/')

    # Set some parameters for formatting feeds
    line_spacing = 1
    line_height = canvas.get_line_height() + line_spacing
    line_width = im_width
    max_lines = im_height // (line_height + line_spacing)

    logger.debug(f"max_lines: {max_lines}")

    # Calculate padding from top so the lines look centralised
    spacing_top = int(im_height % line_height / 2)

    # Calculate line_positions
    line_positions = [(0, spacing_top + _ * line_height) for _ in range(max_lines)]

    logger.debug(f'line positions: {line_positions}')

    logger.info(f'getting xkcd comic...')

    if self.mode == 'random':
        if self.scale_filter == 'no':
            xkcdComic = xkcd.getRandomComic()
            xkcdComic.download(output=tmpPath, outputFile='xkcdComic.png')
        else:
            perc = (2.1, 0.4)
            url = "test variable, not a real comic"
            while max(perc) > 1.75:
                print("looking for another comic, old comic was: ", perc, url)
                xkcdComic = xkcd.getRandomComic()
                xkcdComic.download(output=tmpPath, outputFile='xkcdComic.png')
                actual_size = Image.open(tmpPath + '/xkcdComic.png').size
                perc = (actual_size[0] / im_width, actual_size[1] / im_height)
                url = xkcdComic.getImageLink()
            print("found one! perc: ", perc, url)
    else:
        xkcdComic = xkcd.getLatestComic()
        xkcdComic.download(output=tmpPath, outputFile='xkcdComic.png')

    logger.info(f'got xkcd comic...')
    title_lines = []
    title_lines.append(xkcdComic.getTitle())

    altOffset = int(line_height * 1)

    if self.alt == "yes":
        alt_text = xkcdComic.getAltText()  # get the alt text, too (I break it up into multiple lines later on)

        # break up the alt text into lines
        alt_lines = []
        current_line = ""
        for _ in alt_text.split(" "):
            # this breaks up the alt_text into words and creates each line by adding
            # one word at a time until the line is longer than the width of the module
            # then it appends the line to the alt_lines array and starts testing a new line
            # with the next word
            if canvas.get_text_width(current_line + _ + " ") < im_width:
                current_line = current_line + _ + " "
            else:
                alt_lines.append(current_line)
                current_line = _ + " "
        alt_lines.append(
            current_line)  # this adds the last line to the array (or the only line, if the alt text is really short)
        altHeight = int(line_height * len(alt_lines)) + altOffset
    else:
        altHeight = 0  # this is added so that I don't need to add more "if alt is yes" conditionals when centering below. Now the centering code will work regardless of whether they want alttext or not

    comicSpaceBlack = Image.new('RGBA', (im_width, im_height), (255, 255, 255, 255))
    comicSpaceColour = Image.new('RGBA', (im_width, im_height), (255, 255, 255, 255))

    im = Image2()
    im.load(f"{tmpPath}/xkcdComic.png")
    im.remove_alpha()

    imageAspectRatio = im_width / im_height
    comicAspectRatio = im.image.width / im.image.height

    if comicAspectRatio > imageAspectRatio:
        imageScale = im_width / im.image.width
    else:
        imageScale = im_height / im.image.height

    comicHeight = int(im.image.height * imageScale)

    headerHeight = int(line_height * 3 / 2)

    if comicHeight + (headerHeight + altHeight) > im_height:
        comicHeight -= (headerHeight + altHeight)

    im.resize(width=int(im.image.width * imageScale), height=comicHeight)

    im_comic_black, im_comic_colour = image_to_palette(im.image.convert("RGB"), self.palette)

    headerCenterPosY = int((im_height / 2) - ((im.image.height + headerHeight + altHeight) / 2))
    comicCenterPosY = int((im_height / 2) - ((im.image.height + headerHeight + altHeight) / 2) + headerHeight)
    altCenterPosY = int(
        (im_height / 2) - ((im.image.height + headerHeight + altHeight) / 2) + headerHeight + im.image.height)

    centerPosX = int((im_width / 2) - (im.image.width / 2))

    comicSpaceBlack.paste(im_comic_black, (centerPosX, comicCenterPosY))
    canvas.image_black.paste(comicSpaceBlack)

    comicSpaceColour.paste(im_comic_colour, (centerPosX, comicCenterPosY))
    canvas.image_colour.paste(comicSpaceColour)

    im.clear()
    logger.info(f'added comic image')

    # Write the title on the black image
    canvas.write(
        xy=(0, headerCenterPosY),
        box_size=(line_width, line_height),
        text=title_lines[0]
    )

    if self.alt == "yes":
        # write alt_text
        for _ in range(len(alt_lines)):
            canvas.write(
                xy=(0, altCenterPosY + _ * line_height + altOffset),
                box_size=(line_width, line_height),
                text=alt_lines[_],
                alignment='left'
            )
    # Save image of black and colour channel in image-folder
    return canvas.image_black, canvas.image_colour

Jokes

iCanHazDadJoke module for InkyCal Project Special thanks to Erik Fredericks (@efredericks) for the template!

Copyright by aceinnolab

Jokes

Bases: InkycalModule

Icanhazdad-api class parses rss/atom feeds from given urls

Source code in inkycal/modules/inkycal_jokes.py
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
class Jokes(InkycalModule):
    """Icanhazdad-api class
    parses rss/atom feeds from given urls
    """

    name = "iCanHazDad API - grab a random joke from icanhazdad api"

    def __init__(self, config):
        """Initialize inkycal_feeds module"""

        super().__init__(config)

        config = config['config']

        # give an OK message
        logger.debug(f'{__name__} loaded')

    def generate_image(self):
        """Generate image for this module"""

        # Define new image size with respect to padding
        im_width = int(self.width - (2 * self.padding_left))
        im_height = int(self.height - (2 * self.padding_top))
        im_size = im_width, im_height
        logger.debug(f'image size: {im_width} x {im_height} px')

        canvas = Canvas(im_size, font=self.font, font_size=self.fontsize)

        # Check if internet is available
        if internet_available():
            logger.debug('Connection test passed')
        else:
            logger.error("Network not reachable. Please check your connection.")
            raise NetworkNotReachableError

        # Set some parameters for formatting feeds
        line_spacing = 5
        line_height = canvas.get_line_height()
        line_width = im_width
        max_lines = (im_height // (line_height + line_spacing))

        logger.debug(f"max_lines: {max_lines}")

        # Calculate padding from top so the lines look centralised
        spacing_top = int(im_height % line_height / 2)

        # Calculate line_positions
        line_positions = [
            (0, spacing_top + _ * line_height) for _ in range(max_lines)]

        logger.debug(f'line positions: {line_positions}')

        # Get the actual joke
        url = "https://icanhazdadjoke.com"
        header = {"accept": "text/plain"}
        response = requests.get(url, headers=header)
        response.encoding = 'utf-8'  # Change encoding to UTF-8
        joke = response.text.rstrip()  # use to remove newlines
        logger.debug(f"joke: {joke}")

        # wrap text in case joke is too large
        wrapped = canvas.text_wrap(joke, max_width=line_width)
        logger.debug(f"wrapped: {wrapped}")

        # Check if joke can actually fit on the provided space
        if len(wrapped) > max_lines:
            logger.error("Ohoh, Joke is too large for given space, please consider "
                         "increasing the size for this module")

        # Write the joke on the image
        for _ in range(len(wrapped)):
            if _ + 1 > max_lines:
                logger.error('Ran out of lines for this joke :/')
                break
            canvas.write(
                xy=line_positions[_],
                box_size=(line_width, line_height),
                text=wrapped[_],
                alignment='left'
            )

        # Return images for black and colour channels
        return canvas.image_black, canvas.image_colour

__init__(config)

Initialize inkycal_feeds module

Source code in inkycal/modules/inkycal_jokes.py
33
34
35
36
37
38
39
40
41
def __init__(self, config):
    """Initialize inkycal_feeds module"""

    super().__init__(config)

    config = config['config']

    # give an OK message
    logger.debug(f'{__name__} loaded')

generate_image()

Generate image for this module

Source code in inkycal/modules/inkycal_jokes.py
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
def generate_image(self):
    """Generate image for this module"""

    # Define new image size with respect to padding
    im_width = int(self.width - (2 * self.padding_left))
    im_height = int(self.height - (2 * self.padding_top))
    im_size = im_width, im_height
    logger.debug(f'image size: {im_width} x {im_height} px')

    canvas = Canvas(im_size, font=self.font, font_size=self.fontsize)

    # Check if internet is available
    if internet_available():
        logger.debug('Connection test passed')
    else:
        logger.error("Network not reachable. Please check your connection.")
        raise NetworkNotReachableError

    # Set some parameters for formatting feeds
    line_spacing = 5
    line_height = canvas.get_line_height()
    line_width = im_width
    max_lines = (im_height // (line_height + line_spacing))

    logger.debug(f"max_lines: {max_lines}")

    # Calculate padding from top so the lines look centralised
    spacing_top = int(im_height % line_height / 2)

    # Calculate line_positions
    line_positions = [
        (0, spacing_top + _ * line_height) for _ in range(max_lines)]

    logger.debug(f'line positions: {line_positions}')

    # Get the actual joke
    url = "https://icanhazdadjoke.com"
    header = {"accept": "text/plain"}
    response = requests.get(url, headers=header)
    response.encoding = 'utf-8'  # Change encoding to UTF-8
    joke = response.text.rstrip()  # use to remove newlines
    logger.debug(f"joke: {joke}")

    # wrap text in case joke is too large
    wrapped = canvas.text_wrap(joke, max_width=line_width)
    logger.debug(f"wrapped: {wrapped}")

    # Check if joke can actually fit on the provided space
    if len(wrapped) > max_lines:
        logger.error("Ohoh, Joke is too large for given space, please consider "
                     "increasing the size for this module")

    # Write the joke on the image
    for _ in range(len(wrapped)):
        if _ + 1 > max_lines:
            logger.error('Ran out of lines for this joke :/')
            break
        canvas.write(
            xy=line_positions[_],
            box_size=(line_width, line_height),
            text=wrapped[_],
            alignment='left'
        )

    # Return images for black and colour channels
    return canvas.image_black, canvas.image_colour

Tindie

Tindie module for Inkycal Project Shows unshipped orders from your Tindie store

Copyright by aceinnolab

Tindie

Bases: InkycalModule

Tindie - show latest orders from your store

Source code in inkycal/modules/inkycal_tindie.py
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
class Tindie(InkycalModule):
    """Tindie - show latest orders from your store"""

    def __init__(self, config):
        """Initialize inkycal_feeds module"""

        super().__init__(config)

        config = config['config']
        self.api_key = config['api_key']
        self.username = config['username']
        # todo implement mode
        # self.mode = config['mode']  # unshipped_orders, shipped_orders, all_orders

        # give an OK message
        logger.debug(f'{__name__} loaded')

    def generate_image(self):
        """Generate image for this module"""
        # Define new image size with respect to padding
        im_width = int(self.width - (2 * self.padding_left))
        im_height = int(self.height - (2 * self.padding_top))
        im_size = im_width, im_height
        logger.debug(f'image size: {im_width} x {im_height} px')

        canvas = Canvas(im_size=im_size, font=self.font, font_size=self.fontsize)

        # Check if internet is available
        if internet_available():
            logger.info('Connection test passed')
        else:
            logger.error("Network not reachable. Please check your connection.")
            raise NetworkNotReachableError

        # Set some parameters for formatting feeds
        line_spacing = 5
        line_height = canvas.get_line_height() + line_spacing
        line_width = im_width
        max_lines = (im_height // (line_height + line_spacing))

        logger.debug(f"max_lines: {max_lines}")

        # Calculate padding from top so the lines look centralised
        spacing_top = int(im_height % line_height / 2)

        # Calculate line_positions
        line_positions = [
            (0, spacing_top + _ * line_height) for _ in range(max_lines)]

        logger.debug(f'line positions: {line_positions}')

        # Make the API call
        url = f"https://www.tindie.com/api/v1/order/?format=json&username={self.username}&api_key={self.api_key}"
        header = {"accept": "text/json"}
        response = requests.get(url, headers=header, params={"shipped": "false", "limit": "50"})
        if response.status_code != 200:
            logger.error(f"Failed to get orders, status code: {response.status_code}, reason: {response.reason}.")
            logger.error(f"response: {response.text}")
            raise AssertionError("Failed to get orders")
        else:
            logger.info("Orders received")

        text = []

        orders = json.loads(response.text)["orders"]
        text.append(f"You have {len(orders)} unshipped orders")
        previous_date = None
        for index, order in enumerate(orders, start=1):
            items = order["items"]
            date = arrow.get(order["date"]).to("local").format("YY/MM/DD")
            if not previous_date or previous_date != date:
                text.append(date)
                previous_date = date
            user_name = order["shipping_name"]
            text.append(f"{index}) {user_name} from {order['shipping_country_code']} ordered {len(items)} items!")

        for pos, line in enumerate(text):
            if pos > max_lines - 1:
                logger.error(f'Ran out of lines! Required {len(text)} lines but only {max_lines} available')
                break
            if pos == 0:
                canvas.write(
                    xy=line_positions[pos],
                    box_size=(line_width, line_height),
                    text=line,
                    alignment='left',
                    colour="colour"
                )
            else:
                canvas.write(
                    xy=line_positions[pos],
                    box_size=(line_width, line_height),
                    text=line,
                    alignment='left',
                )


        # Return images for black and colour channels
        return canvas.image_black, canvas.image_colour

__init__(config)

Initialize inkycal_feeds module

Source code in inkycal/modules/inkycal_tindie.py
27
28
29
30
31
32
33
34
35
36
37
38
39
def __init__(self, config):
    """Initialize inkycal_feeds module"""

    super().__init__(config)

    config = config['config']
    self.api_key = config['api_key']
    self.username = config['username']
    # todo implement mode
    # self.mode = config['mode']  # unshipped_orders, shipped_orders, all_orders

    # give an OK message
    logger.debug(f'{__name__} loaded')

generate_image()

Generate image for this module

Source code in inkycal/modules/inkycal_tindie.py
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
def generate_image(self):
    """Generate image for this module"""
    # Define new image size with respect to padding
    im_width = int(self.width - (2 * self.padding_left))
    im_height = int(self.height - (2 * self.padding_top))
    im_size = im_width, im_height
    logger.debug(f'image size: {im_width} x {im_height} px')

    canvas = Canvas(im_size=im_size, font=self.font, font_size=self.fontsize)

    # Check if internet is available
    if internet_available():
        logger.info('Connection test passed')
    else:
        logger.error("Network not reachable. Please check your connection.")
        raise NetworkNotReachableError

    # Set some parameters for formatting feeds
    line_spacing = 5
    line_height = canvas.get_line_height() + line_spacing
    line_width = im_width
    max_lines = (im_height // (line_height + line_spacing))

    logger.debug(f"max_lines: {max_lines}")

    # Calculate padding from top so the lines look centralised
    spacing_top = int(im_height % line_height / 2)

    # Calculate line_positions
    line_positions = [
        (0, spacing_top + _ * line_height) for _ in range(max_lines)]

    logger.debug(f'line positions: {line_positions}')

    # Make the API call
    url = f"https://www.tindie.com/api/v1/order/?format=json&username={self.username}&api_key={self.api_key}"
    header = {"accept": "text/json"}
    response = requests.get(url, headers=header, params={"shipped": "false", "limit": "50"})
    if response.status_code != 200:
        logger.error(f"Failed to get orders, status code: {response.status_code}, reason: {response.reason}.")
        logger.error(f"response: {response.text}")
        raise AssertionError("Failed to get orders")
    else:
        logger.info("Orders received")

    text = []

    orders = json.loads(response.text)["orders"]
    text.append(f"You have {len(orders)} unshipped orders")
    previous_date = None
    for index, order in enumerate(orders, start=1):
        items = order["items"]
        date = arrow.get(order["date"]).to("local").format("YY/MM/DD")
        if not previous_date or previous_date != date:
            text.append(date)
            previous_date = date
        user_name = order["shipping_name"]
        text.append(f"{index}) {user_name} from {order['shipping_country_code']} ordered {len(items)} items!")

    for pos, line in enumerate(text):
        if pos > max_lines - 1:
            logger.error(f'Ran out of lines! Required {len(text)} lines but only {max_lines} available')
            break
        if pos == 0:
            canvas.write(
                xy=line_positions[pos],
                box_size=(line_width, line_height),
                text=line,
                alignment='left',
                colour="colour"
            )
        else:
            canvas.write(
                xy=line_positions[pos],
                box_size=(line_width, line_height),
                text=line,
                alignment='left',
            )


    # Return images for black and colour channels
    return canvas.image_black, canvas.image_colour

Text File Renderer

Textfile module for InkyCal Project

Reads data from a plain .txt file and renders it on the display. If the content is too long, it will be truncated from the back until it fits

Copyright by aceinnolab

TextToDisplay

Bases: InkycalModule

TextToDisplay module - Display text from a local file on the display

Source code in inkycal/modules/inkycal_textfile_to_display.py
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
class TextToDisplay(InkycalModule):
    """TextToDisplay module - Display text from a local file on the display
    """
    name = "TextToDisplay - Display text from a local file on the display"

    def __init__(self, config):
        """Initialize inkycal_textfile_to_display module"""

        super().__init__(config)

        config = config['config']
        # required parameters
        self.filepath = config["filepath"]

        self.make_request = True if self.filepath.startswith("https://") else False

        # give an OK message
        logger.debug(f'{__name__} loaded')

    def _validate(self):
        """Validate module-specific parameters"""
        # ensure we only use a single file
        assert (self.filepath and len(self.filepath) == 1)

    def generate_image(self):
        """Generate image for this module"""

        # Define new image size with respect to padding
        file_content = None
        im_width = int(self.width - (2 * self.padding_left))
        im_height = int(self.height - (2 * self.padding_top))
        im_size = im_width, im_height
        logger.debug(f'Image size: {im_size}')

        canvas = Canvas(im_size=im_size, font=self.font, font_size=self.fontsize)

        # Set some parameters for formatting feeds
        line_spacing = 4
        line_height = canvas.get_line_height() + line_spacing
        line_width = im_width
        max_lines = im_height // line_height

        # Calculate padding from top so the lines look centralised
        spacing_top = int(im_height % line_height / 2)

        # Calculate line_positions
        line_positions = [
            (0, spacing_top + _ * line_height) for _ in range(max_lines)]

        if self.make_request:
            logger.info("Detected http path, making request")
            # Check if internet is available
            if internet_available():
                logger.info('Connection test passed')
            else:
                raise NetworkNotReachableError
            file_content = urlopen(self.filepath).read().decode('utf-8')
        else:
            # Create list containing all lines
            with open(self.filepath, 'r') as file:
                file_content = file.read()

        # Split content by lines if not making a request
        if not self.make_request:
            lines = file_content.split('\n')
        else:
            lines = canvas.text_wrap(file_content, max_width=im_width)

        # Trim down the list to the max number of lines
        del lines[max_lines:]

        # Write feeds on image
        for index, line in enumerate(lines):
            canvas.write(
                xy=line_positions[index],
                box_size=(line_width, line_height),
                text=line,
                alignment='left'
            )

        # return images
        return canvas.image_black, canvas.image_colour

__init__(config)

Initialize inkycal_textfile_to_display module

Source code in inkycal/modules/inkycal_textfile_to_display.py
27
28
29
30
31
32
33
34
35
36
37
38
39
def __init__(self, config):
    """Initialize inkycal_textfile_to_display module"""

    super().__init__(config)

    config = config['config']
    # required parameters
    self.filepath = config["filepath"]

    self.make_request = True if self.filepath.startswith("https://") else False

    # give an OK message
    logger.debug(f'{__name__} loaded')

generate_image()

Generate image for this module

Source code in inkycal/modules/inkycal_textfile_to_display.py
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
def generate_image(self):
    """Generate image for this module"""

    # Define new image size with respect to padding
    file_content = None
    im_width = int(self.width - (2 * self.padding_left))
    im_height = int(self.height - (2 * self.padding_top))
    im_size = im_width, im_height
    logger.debug(f'Image size: {im_size}')

    canvas = Canvas(im_size=im_size, font=self.font, font_size=self.fontsize)

    # Set some parameters for formatting feeds
    line_spacing = 4
    line_height = canvas.get_line_height() + line_spacing
    line_width = im_width
    max_lines = im_height // line_height

    # Calculate padding from top so the lines look centralised
    spacing_top = int(im_height % line_height / 2)

    # Calculate line_positions
    line_positions = [
        (0, spacing_top + _ * line_height) for _ in range(max_lines)]

    if self.make_request:
        logger.info("Detected http path, making request")
        # Check if internet is available
        if internet_available():
            logger.info('Connection test passed')
        else:
            raise NetworkNotReachableError
        file_content = urlopen(self.filepath).read().decode('utf-8')
    else:
        # Create list containing all lines
        with open(self.filepath, 'r') as file:
            file_content = file.read()

    # Split content by lines if not making a request
    if not self.make_request:
        lines = file_content.split('\n')
    else:
        lines = canvas.text_wrap(file_content, max_width=im_width)

    # Trim down the list to the max number of lines
    del lines[max_lines:]

    # Write feeds on image
    for index, line in enumerate(lines):
        canvas.write(
            xy=line_positions[index],
            box_size=(line_width, line_height),
            text=line,
            alignment='left'
        )

    # return images
    return canvas.image_black, canvas.image_colour

🖥 Display API

Inkycal ePaper Display Driver Abstraction

This module provides the high-level Display class used by Inkycal for rendering images on supported E-Paper displays. It dynamically loads the appropriate hardware driver based on the selected model and provides:

  • Rendering black/white or black/white/colour images
  • Automatic fallback checks
  • Display calibration
  • Utility helpers for accessing supported display models

All hardware driver implementations are expected to provide a EPD class with methods:

  • init()
  • display(buffer_black, buffer_colour=None)
  • getbuffer(image)
  • sleep()

Display

High-level interface for rendering images on an ePaper display.

The Display class wraps the low-level hardware driver for the selected E-Paper model and offers simplified rendering and calibration routines.

Parameters:

Name Type Description Default
epaper_model str

Name of the display model, e.g. "waveshare_7in5_colour".

required

Raises:

Type Description
Exception

If the driver module cannot be imported or if SPI appears

Source code in inkycal/display/display.py
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
class Display:
    """High-level interface for rendering images on an ePaper display.

    The Display class wraps the low-level hardware driver for the selected
    E-Paper model and offers simplified rendering and calibration routines.

    Args:
        epaper_model (str):
            Name of the display model, e.g. ``"waveshare_7in5_colour"``.

    Raises:
        Exception: If the driver module cannot be imported or if SPI appears
        unavailable.
    """

    # ----------------------------------------------------------------------
    # Initialization
    # ----------------------------------------------------------------------
    def __init__(self, epaper_model: str) -> None:
        """Load and initialize the driver for the given E-Paper model."""

        self.supports_colour = "colour" in epaper_model

        try:
            driver = import_driver(epaper_model)
            self._epaper = driver.EPD()
            self.model_name = epaper_model

        except ImportError:
            raise Exception(
                f"Display model '{epaper_model}' is not supported. "
                "Check spelling or supported models list."
            )

        except FileNotFoundError:
            raise Exception(
                "SPI interface could not be initialized. "
                "Ensure SPI is enabled on your system."
            )

    # ----------------------------------------------------------------------
    # Rendering
    # ----------------------------------------------------------------------
    def render(self, im_black: Image.Image, im_colour: Optional[Image.Image] = None) -> None:
        """Render one or two images on the selected E-Paper display.

        Args:
            im_black (PIL.Image):
                The image representing black pixels. Required for **all**
                supported E-Paper types. Anything non-white becomes black.

            im_colour (PIL.Image, optional):
                The image representing colour pixels (red/yellow). Required only
                when the selected display supports colour. Anything non-white
                becomes coloured.

        Raises:
            Exception: If a colour display is used without ``im_colour``.

        Examples:
            Rendering a black-white image:

            >>> img = Image.open("image.png")
            >>> disp = Display("waveshare_7in5")
            >>> disp.render(img)

            Rendering black-white on a colour display:

            >>> img = Image.open("image.png")
            >>> disp = Display("waveshare_7in5_colour")
            >>> disp.render(img, img)

            Rendering fully separated black + colour channels:

            >>> bw = Image.open("bw.png")
            >>> col = Image.open("col.png")
            >>> disp.render(bw, col)
        """
        epaper = self._epaper

        # Initialize and update
        print("Initialising..", end="")
        epaper.init()

        print("Updating display......", end="")
        if self.supports_colour:
            if im_colour is None:
                raise Exception(
                    "im_colour is required for colour E-Paper displays."
                )
            epaper.display(
                epaper.getbuffer(im_black),
                epaper.getbuffer(im_colour),
            )
        else:
            epaper.display(epaper.getbuffer(im_black))

        print("Done")

        # Put display into deep sleep to reduce ghosting and power usage
        print("Sending E-Paper to deep sleep...", end="")
        epaper.sleep()
        print("Done")

    # ----------------------------------------------------------------------
    # Calibration
    # ----------------------------------------------------------------------
    def calibrate(self, cycles: int = 3) -> None:
        """Calibrate the display to reduce ghosting and restore contrast.

        Performs repeated full-screen refresh cycles using black/white or
        black/white/colour depending on the display type.

        Args:
            cycles (int):
                Number of calibration cycles. More cycles produce cleaner
                results but take longer.

        Notes:
            - Black/white displays: ~10 minutes for 3 cycles.
            - Colour displays: ~20 minutes for 3 cycles.
            - Recommended: run calibration every **~6 updates**.

        Raises:
            RuntimeError: If display initialization fails.
        """
        epaper = self._epaper
        epaper.init()

        display_size = self.get_display_size(self.model_name)

        white = Image.new("1", display_size, "white")
        black = Image.new("1", display_size, "black")

        print("---------- Starting calibration ----------")

        if self.supports_colour:
            # black → colour → white
            for i in range(cycles):
                print(f"Cycle {i+1}/{cycles}: black...", end=" ")
                epaper.display(epaper.getbuffer(black), epaper.getbuffer(white))

                print("colour...", end=" ")
                epaper.display(epaper.getbuffer(white), epaper.getbuffer(black))

                print("white...")
                epaper.display(epaper.getbuffer(white), epaper.getbuffer(white))

        else:
            # black → white
            for i in range(cycles):
                print(f"Cycle {i+1}/{cycles}: black...", end=" ")
                epaper.display(epaper.getbuffer(black))

                print("white...")
                epaper.display(epaper.getbuffer(white))

            epaper.sleep()

        print("---------- Calibration complete ----------")

    # ----------------------------------------------------------------------
    # Display information helpers
    # ----------------------------------------------------------------------
    @classmethod
    def get_display_size(cls, model_name: str) -> Tuple[int, int]:
        """Return the pixel size of a supported display.

        Args:
            model_name (str):
                Display model identifier (key in ``supported_models``).

        Returns:
            Tuple[int, int]: The display resolution as ``(width, height)``.

        Raises:
            AssertionError: If the model is not found.

        Example:
            >>> Display.get_display_size("waveshare_7in5")
            (800, 480)
        """
        if model_name in supported_models:
            return supported_models[model_name]

        raise AssertionError(f"'{model_name}' not found in supported models")

    @classmethod
    def get_display_names(cls) -> List[str]:
        """Return a list of all supported E-Paper model names.

        Returns:
            List[str]: All supported display identifiers.

        Example:
            >>> Display.get_display_names()
            ['waveshare_7in5', 'waveshare_7in5_colour', ...]
        """
        return list(supported_models.keys())

    # ----------------------------------------------------------------------
    # Utility: simple text rendering
    # ----------------------------------------------------------------------
    def render_text(self, text: str, font_size: int = 24, max_width_ratio: float = 0.95) -> None:
        """Render a centered, auto-wrapped text message on the display.

        This is primarily used for setup messages, error reporting,
        or simple system notifications.

        Args:
            text (str):
                Text to display. Auto-wrapped to fit screen width.

            font_size (int):
                Base font size used to render text.

            max_width_ratio (float):
                Maximum fraction of screen width allowed for text lines.

        Raises:
            Exception: If the display cannot be initialized or rendered.

        Example:
            >>> disp = Display("waveshare_7in5")
            >>> disp.render_text("Hello world!")
        """
        from PIL import ImageDraw, ImageFont
        from inkycal.utils.enums import FONTS

        # Fetch resolution (Inkycal rotates images internally)
        height, width = self.get_display_size(self.model_name)

        # Load font
        font = ImageFont.truetype(FONTS.default.value, font_size)

        # Temporary canvas for measurements
        temp_img = Image.new("1", (width, height), "white")
        draw = ImageDraw.Draw(temp_img)

        # Helper: measure text line
        def measure(line: str):
            bbox = draw.textbbox((0, 0), line, font=font)
            return bbox[2] - bbox[0], bbox[3] - bbox[1]

        max_width_px = int(width * max_width_ratio)

        # Auto-wrap
        words = text.split()
        lines = []
        current = []

        for word in words:
            test = " ".join(current + [word])
            w, _ = measure(test)
            if w <= max_width_px:
                current.append(word)
            else:
                lines.append(" ".join(current))
                current = [word]

        if current:
            lines.append(" ".join(current))

        # Measure block height
        line_sizes = [measure(line) for line in lines]
        total_height = sum(h for _, h in line_sizes)
        y = (height - total_height) // 2

        # Final BW image
        img_bw = Image.new("1", (width, height), "white")
        draw_final = ImageDraw.Draw(img_bw)

        for line, (w, h) in zip(lines, line_sizes):
            x = (width - w) // 2
            draw_final.text((x, y), line, fill="black", font=font)
            y += h

        # Dummy colour channel
        img_colour = Image.new("1", (width, height), "white")

        self.render(img_bw, img_colour)

__init__(epaper_model)

Load and initialize the driver for the given E-Paper model.

Source code in inkycal/display/display.py
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
def __init__(self, epaper_model: str) -> None:
    """Load and initialize the driver for the given E-Paper model."""

    self.supports_colour = "colour" in epaper_model

    try:
        driver = import_driver(epaper_model)
        self._epaper = driver.EPD()
        self.model_name = epaper_model

    except ImportError:
        raise Exception(
            f"Display model '{epaper_model}' is not supported. "
            "Check spelling or supported models list."
        )

    except FileNotFoundError:
        raise Exception(
            "SPI interface could not be initialized. "
            "Ensure SPI is enabled on your system."
        )

calibrate(cycles=3)

Calibrate the display to reduce ghosting and restore contrast.

Performs repeated full-screen refresh cycles using black/white or black/white/colour depending on the display type.

Parameters:

Name Type Description Default
cycles int

Number of calibration cycles. More cycles produce cleaner results but take longer.

3
Notes
  • Black/white displays: ~10 minutes for 3 cycles.
  • Colour displays: ~20 minutes for 3 cycles.
  • Recommended: run calibration every ~6 updates.

Raises:

Type Description
RuntimeError

If display initialization fails.

Source code in inkycal/display/display.py
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
def calibrate(self, cycles: int = 3) -> None:
    """Calibrate the display to reduce ghosting and restore contrast.

    Performs repeated full-screen refresh cycles using black/white or
    black/white/colour depending on the display type.

    Args:
        cycles (int):
            Number of calibration cycles. More cycles produce cleaner
            results but take longer.

    Notes:
        - Black/white displays: ~10 minutes for 3 cycles.
        - Colour displays: ~20 minutes for 3 cycles.
        - Recommended: run calibration every **~6 updates**.

    Raises:
        RuntimeError: If display initialization fails.
    """
    epaper = self._epaper
    epaper.init()

    display_size = self.get_display_size(self.model_name)

    white = Image.new("1", display_size, "white")
    black = Image.new("1", display_size, "black")

    print("---------- Starting calibration ----------")

    if self.supports_colour:
        # black → colour → white
        for i in range(cycles):
            print(f"Cycle {i+1}/{cycles}: black...", end=" ")
            epaper.display(epaper.getbuffer(black), epaper.getbuffer(white))

            print("colour...", end=" ")
            epaper.display(epaper.getbuffer(white), epaper.getbuffer(black))

            print("white...")
            epaper.display(epaper.getbuffer(white), epaper.getbuffer(white))

    else:
        # black → white
        for i in range(cycles):
            print(f"Cycle {i+1}/{cycles}: black...", end=" ")
            epaper.display(epaper.getbuffer(black))

            print("white...")
            epaper.display(epaper.getbuffer(white))

        epaper.sleep()

    print("---------- Calibration complete ----------")

get_display_names() classmethod

Return a list of all supported E-Paper model names.

Returns:

Type Description
List[str]

List[str]: All supported display identifiers.

Example

Display.get_display_names() ['waveshare_7in5', 'waveshare_7in5_colour', ...]

Source code in inkycal/display/display.py
221
222
223
224
225
226
227
228
229
230
231
232
@classmethod
def get_display_names(cls) -> List[str]:
    """Return a list of all supported E-Paper model names.

    Returns:
        List[str]: All supported display identifiers.

    Example:
        >>> Display.get_display_names()
        ['waveshare_7in5', 'waveshare_7in5_colour', ...]
    """
    return list(supported_models.keys())

get_display_size(model_name) classmethod

Return the pixel size of a supported display.

Parameters:

Name Type Description Default
model_name str

Display model identifier (key in supported_models).

required

Returns:

Type Description
Tuple[int, int]

Tuple[int, int]: The display resolution as (width, height).

Raises:

Type Description
AssertionError

If the model is not found.

Example

Display.get_display_size("waveshare_7in5") (800, 480)

Source code in inkycal/display/display.py
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
@classmethod
def get_display_size(cls, model_name: str) -> Tuple[int, int]:
    """Return the pixel size of a supported display.

    Args:
        model_name (str):
            Display model identifier (key in ``supported_models``).

    Returns:
        Tuple[int, int]: The display resolution as ``(width, height)``.

    Raises:
        AssertionError: If the model is not found.

    Example:
        >>> Display.get_display_size("waveshare_7in5")
        (800, 480)
    """
    if model_name in supported_models:
        return supported_models[model_name]

    raise AssertionError(f"'{model_name}' not found in supported models")

render(im_black, im_colour=None)

Render one or two images on the selected E-Paper display.

Parameters:

Name Type Description Default
im_black Image

The image representing black pixels. Required for all supported E-Paper types. Anything non-white becomes black.

required
im_colour Image

The image representing colour pixels (red/yellow). Required only when the selected display supports colour. Anything non-white becomes coloured.

None

Raises:

Type Description
Exception

If a colour display is used without im_colour.

Examples:

Rendering a black-white image:

>>> img = Image.open("image.png")
>>> disp = Display("waveshare_7in5")
>>> disp.render(img)

Rendering black-white on a colour display:

>>> img = Image.open("image.png")
>>> disp = Display("waveshare_7in5_colour")
>>> disp.render(img, img)

Rendering fully separated black + colour channels:

>>> bw = Image.open("bw.png")
>>> col = Image.open("col.png")
>>> disp.render(bw, col)
Source code in inkycal/display/display.py
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
def render(self, im_black: Image.Image, im_colour: Optional[Image.Image] = None) -> None:
    """Render one or two images on the selected E-Paper display.

    Args:
        im_black (PIL.Image):
            The image representing black pixels. Required for **all**
            supported E-Paper types. Anything non-white becomes black.

        im_colour (PIL.Image, optional):
            The image representing colour pixels (red/yellow). Required only
            when the selected display supports colour. Anything non-white
            becomes coloured.

    Raises:
        Exception: If a colour display is used without ``im_colour``.

    Examples:
        Rendering a black-white image:

        >>> img = Image.open("image.png")
        >>> disp = Display("waveshare_7in5")
        >>> disp.render(img)

        Rendering black-white on a colour display:

        >>> img = Image.open("image.png")
        >>> disp = Display("waveshare_7in5_colour")
        >>> disp.render(img, img)

        Rendering fully separated black + colour channels:

        >>> bw = Image.open("bw.png")
        >>> col = Image.open("col.png")
        >>> disp.render(bw, col)
    """
    epaper = self._epaper

    # Initialize and update
    print("Initialising..", end="")
    epaper.init()

    print("Updating display......", end="")
    if self.supports_colour:
        if im_colour is None:
            raise Exception(
                "im_colour is required for colour E-Paper displays."
            )
        epaper.display(
            epaper.getbuffer(im_black),
            epaper.getbuffer(im_colour),
        )
    else:
        epaper.display(epaper.getbuffer(im_black))

    print("Done")

    # Put display into deep sleep to reduce ghosting and power usage
    print("Sending E-Paper to deep sleep...", end="")
    epaper.sleep()
    print("Done")

render_text(text, font_size=24, max_width_ratio=0.95)

Render a centered, auto-wrapped text message on the display.

This is primarily used for setup messages, error reporting, or simple system notifications.

Parameters:

Name Type Description Default
text str

Text to display. Auto-wrapped to fit screen width.

required
font_size int

Base font size used to render text.

24
max_width_ratio float

Maximum fraction of screen width allowed for text lines.

0.95

Raises:

Type Description
Exception

If the display cannot be initialized or rendered.

Example

disp = Display("waveshare_7in5") disp.render_text("Hello world!")

Source code in inkycal/display/display.py
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
def render_text(self, text: str, font_size: int = 24, max_width_ratio: float = 0.95) -> None:
    """Render a centered, auto-wrapped text message on the display.

    This is primarily used for setup messages, error reporting,
    or simple system notifications.

    Args:
        text (str):
            Text to display. Auto-wrapped to fit screen width.

        font_size (int):
            Base font size used to render text.

        max_width_ratio (float):
            Maximum fraction of screen width allowed for text lines.

    Raises:
        Exception: If the display cannot be initialized or rendered.

    Example:
        >>> disp = Display("waveshare_7in5")
        >>> disp.render_text("Hello world!")
    """
    from PIL import ImageDraw, ImageFont
    from inkycal.utils.enums import FONTS

    # Fetch resolution (Inkycal rotates images internally)
    height, width = self.get_display_size(self.model_name)

    # Load font
    font = ImageFont.truetype(FONTS.default.value, font_size)

    # Temporary canvas for measurements
    temp_img = Image.new("1", (width, height), "white")
    draw = ImageDraw.Draw(temp_img)

    # Helper: measure text line
    def measure(line: str):
        bbox = draw.textbbox((0, 0), line, font=font)
        return bbox[2] - bbox[0], bbox[3] - bbox[1]

    max_width_px = int(width * max_width_ratio)

    # Auto-wrap
    words = text.split()
    lines = []
    current = []

    for word in words:
        test = " ".join(current + [word])
        w, _ = measure(test)
        if w <= max_width_px:
            current.append(word)
        else:
            lines.append(" ".join(current))
            current = [word]

    if current:
        lines.append(" ".join(current))

    # Measure block height
    line_sizes = [measure(line) for line in lines]
    total_height = sum(h for _, h in line_sizes)
    y = (height - total_height) // 2

    # Final BW image
    img_bw = Image.new("1", (width, height), "white")
    draw_final = ImageDraw.Draw(img_bw)

    for line, (w, h) in zip(lines, line_sizes):
        x = (width - w) // 2
        draw_final.text((x, y), line, fill="black", font=font)
        y += h

    # Dummy colour channel
    img_colour = Image.new("1", (width, height), "white")

    self.render(img_bw, img_colour)

import_driver(model)

Dynamically import a driver module for the given display model.

Source code in inkycal/display/display.py
29
30
31
def import_driver(model: str):
    """Dynamically import a driver module for the given display model."""
    return import_module(f"inkycal.display.drivers.{model}")

🎨 Canvas API

This is one of the most important components of Inkycal.
It is used to render text, icons, shapes, and previews.

Auto-generate docs:

canvas.py

Canvas

Canvas class of Inkycal. Set this up once and use to draw text on a PIL Image.

Source code in inkycal/utils/canvas.py
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
class Canvas:
    """Canvas class of Inkycal. Set this up once and use to draw text on a PIL Image."""
    def __init__(self, im_size:Tuple[int, int], font: FONTS, font_size: int):
        self._font = ImageFont.truetype(font.value, font_size)
        self.font_enum = font
        self._font_size = font_size
        self.image_black = Image.new('RGB', size=im_size, color='white')
        self.image_colour = Image.new('RGB', size=im_size, color='white')


    def set_font_size(self, font_size: int):
        """Set the font size to use"""
        self._font_size = font_size

    @property
    def font_size(self):
        return self._font_size

    @property
    def size(self):
        return self.image_black.size

    def set_font(self, font:FONTS, font_size: Optional[int]):
        self.font_enum = font
        self._font = ImageFont.truetype(font, font_size if font_size else self._font_size)

    @property
    def font(self) -> FONTS:
        return self.font_enum

    def write(
            self,
            xy: Tuple[int, int],
            box_size: Tuple[int, int],
            text: str,
            *,
            alignment: Literal["center", "left", "right"] = "center",
            autofit: bool = False,
            colour: Literal["black", "colour"] = "black",
            rotation: Optional[float] = None,
            fill_width: float = 1.0,
            fill_height: float = 0.8,
    ) -> None:
        """
        Write (possibly multi-line) text inside a rectangle.
        Supports '\n' and auto-wrapping inside each line.
        """

        box_x, box_y = xy
        box_w, box_h = box_size

        font_path = self.font_enum.value
        font = self._font
        size = self._font_size

        # ----------------------------
        # 1) Auto-fit the font size
        # ----------------------------
        if autofit or (fill_width != 1.0) or (fill_height != 0.8):
            size = max(8, size)
            while True:
                font = _load_font(font_path, size)

                # measure test height with 1-line sample
                sample_h = font.getbbox("Ag")[3] - font.getbbox("Ag")[1]

                if sample_h >= int(box_h * fill_height):
                    if size > 8:
                        size -= 1
                    font = _load_font(font_path, size)
                    break

                size += 1

            self._font_size = size
            self._font = font

        # ----------------------------
        # 2) Split text into logical lines
        # ----------------------------
        logical_lines = text.split("\n")

        # ----------------------------
        # 3) Wrap each line to the box width
        # ----------------------------
        wrapped_lines: list[str] = []
        for line in logical_lines:
            wrapped_lines.extend(self.text_wrap(line, max_width=int(box_w * fill_width)))

        if not wrapped_lines:
            return

        # ----------------------------
        # 4) Measure combined height
        # ----------------------------
        line_heights = []
        total_h = 0

        for line in wrapped_lines:
            bbox = font.getbbox(line)
            h = bbox[3] - bbox[1]
            line_heights.append(h)
            total_h += h

        # add minimal line spacing (you can tune this)
        line_spacing = int(font.size * 0.2)
        total_h += line_spacing * (len(wrapped_lines) - 1)

        # If text block too tall → truncate bottom lines
        while total_h > box_h and wrapped_lines:
            removed = wrapped_lines.pop()
            removed_h = line_heights.pop()
            total_h -= (removed_h + line_spacing)

        if not wrapped_lines:
            return

        # ----------------------------
        # 5) Vertical centering
        # ----------------------------
        cy = box_y + (box_h - total_h) // 2

        # ----------------------------
        # 6) Create transparent layer and draw lines
        # ----------------------------
        space = Image.new("RGBA", (box_w, box_h), (0, 0, 0, 0))
        draw = ImageDraw.Draw(space)

        py = 0
        for line, lh in zip(wrapped_lines, line_heights):

            # horizontal alignment
            line_w = font.getbbox(line)[2] - font.getbbox(line)[0]

            if alignment == "center":
                px = (box_w - line_w) // 2
            elif alignment == "left":
                px = 0
            elif alignment == "right":
                px = box_w - line_w
            else:
                px = (box_w - line_w) // 2

            draw.text((px, py), line, fill="black", font=font)
            py += lh + line_spacing

        # ----------------------------
        # 7) Rotation + paste
        # ----------------------------
        if rotation:
            space = space.rotate(rotation, expand=True)

        # Always draw on black first
        self.image_black.paste(space, xy, space)

        # Colour overlay if needed
        if colour == "colour":
            self.image_colour.paste(space, xy, space)

    def text_wrap(self, text: str, max_width: int) -> list[str]:
        """
        Split long text into wrapped lines using the Canvas' current font.

        Args:
            text: The full text to wrap.
            max_width: Maximum pixel width allowed per line.

        Returns:
            A list of strings, each representing one wrapped line.
        """

        font = self._font
        words = text.split(" ")
        lines = []
        current_line = ""

        for word in words:
            # Test candidate line
            proposed = (current_line + " " + word).strip()

            if font.getlength(proposed) <= max_width:
                # Word fits — extend current line
                current_line = proposed
            else:
                # Word does not fit
                if not current_line:
                    # Word itself too long: force-break
                    lines.append(word)
                else:
                    # Push current line and start a new one
                    lines.append(current_line)
                    current_line = word

        # Add final line
        if current_line:
            lines.append(current_line)

        return lines

    def auto_fontsize(self, max_height: int, sample_text: str = "Ag", target_ratio: float = 0.80):
        """
        Automatically scale the canvas' font so its height reaches ~target_ratio
        of the given max_height.

        Args:
            max_height (int): Maximum allowed pixel height.
            sample_text (str): Text used to measure font height. Default "Ag".
            target_ratio (float): The portion of max_height the font should fill.

        Returns:
            None — self.font and self._font_size are updated.
        """

        best_size = 1
        target_height = max_height * target_ratio

        # Start from the current size
        size = self._font_size

        # Increment font size until height overshoots target
        while True:
            font = _load_font(self.font_enum.value, size)
            bbox = font.getbbox(sample_text)
            height = bbox[3] - bbox[1]

            if height > target_height:
                break

            best_size = size
            size += 1

        # Load the chosen font size
        self._font_size = best_size
        font_path = self.font_enum.value
        self._font = _load_font(font_path, best_size)

    def get_line_height(self, sample_text: str = "Ag") -> int:
        """
        Return the pixel line height of the currently active font.
        Based on ascent + descent, with a reliable fallback if unsupported.

        Args:
            sample_text (str): A sample string used to measure font height.

        Returns:
            int — Line height in pixels.
        """
        try:
            ascent, descent = self._font.getmetrics()
            return ascent + descent
        except Exception:
            # Fallback using bounding box
            bbox = self._font.getbbox(sample_text)
            return int(bbox[3] - bbox[1])

    def get_text_width(self, text: str) -> int:
        """
        Return the rendered width of a string using the current font.

        Args:
            text (str): The text to measure.

        Returns:
            int — Width in pixels.
        """
        bbox = self._font.getbbox(text)
        return int(bbox[2] - bbox[0])

    def draw_icon(
            self,
            xy: Tuple[int, int],
            box_size: Tuple[int, int],
            icon: str,
            colour: Literal["black", "colour"] = "black",
            rotation: Optional[float] = None,
            fill_ratio: float = 0.90,
            font: Optional[FONTS] = None,
    ) -> None:

        box_x, box_y = xy
        box_w, box_h = box_size

        # Select icon font
        font_enum = font or FONTS.weather_icons
        font_path = font_enum.value

        # --- Determine max usable size ---
        size = 8
        while True:
            test_font = _load_font(font_path, size)
            bbox = test_font.getbbox(icon)
            w = bbox[2] - bbox[0]
            h = bbox[3] - bbox[1]
            if w >= box_w * fill_ratio or h >= box_h * fill_ratio:
                size = max(8, size - 1)
                break
            size += 1

        font_final = _load_font(font_path, size)

        # --- TEMP CANVAS FOR PIXEL ANALYSIS ---
        temp_w = box_w * 2
        temp_h = box_h * 2

        # 1) Render icon for ALPHA extraction
        temp_alpha = Image.new("L", (temp_w, temp_h), 0)
        dA = ImageDraw.Draw(temp_alpha)
        dA.text((temp_w // 2, temp_h // 2), icon, fill=255, font=font_final, anchor="mm")

        # Convert to numpy
        import numpy as np
        arr = np.asarray(temp_alpha)

        # Detect ink pixels
        mask = arr > 10  # threshold to keep fill

        if not mask.any():
            return

        ys, xs = np.where(mask)
        min_x, max_x = xs.min(), xs.max()
        min_y, max_y = ys.min(), ys.max()

        ink_w = max_x - min_x + 1
        ink_h = max_y - min_y + 1

        # Extract the alpha mask for that region
        mask_region = temp_alpha.crop((min_x, min_y, max_x + 1, max_y + 1))

        # --- 2) Render icon again as RGB fill (full-strength black) ---
        temp_rgb = Image.new("RGB", (temp_w, temp_h), "white")
        dR = ImageDraw.Draw(temp_rgb)
        dR.text((temp_w // 2, temp_h // 2), icon, fill="black", font=font_final, anchor="mm")

        rgb_region = temp_rgb.crop((min_x, min_y, max_x + 1, max_y + 1))

        # --- 3) Combine into RGBA for final paste ---
        layer = Image.new("RGBA", (box_w, box_h), (0, 0, 0, 0))

        paste_x = (box_w - ink_w) // 2
        paste_y = (box_h - ink_h) // 2

        layer.paste(rgb_region, (paste_x, paste_y), mask_region)

        if rotation:
            layer = layer.rotate(rotation, expand=True)

        # Paste to black layer
        self.image_black.paste(layer, xy, layer)

        # Paste to colour layer if needed
        if colour == "colour":
            self.image_colour.paste(layer, xy, layer)

    @staticmethod
    def _optimize_for_red_preview(img: Image.Image, threshold: int = 200) -> Image.Image:
        """
        Normalize coloured-image contrast before converting to red.
        Dark pixels → black
        Light pixels → white
        Threshold-based cleanup prevents blurry thick red shapes.
        """
        arr = numpy.asarray(img.convert("RGB")).copy()

        red = arr[:, :, 0]
        green = arr[:, :, 1]
        blue = arr[:, :, 2]

        # Identify dark-ish pixels → treat as black
        dark_mask = (red <= threshold) & (green <= threshold) & (blue <= threshold)

        # Everything else becomes pure white
        arr[~dark_mask] = [255, 255, 255]
        arr[dark_mask] = [0, 0, 0]

        return Image.fromarray(arr)

    @staticmethod
    def color_to_red(img: Image.Image) -> Image.Image:
        """
        Convert dark pixels to red with alpha transparency.
        Uses optimized thresholding for more accurate previews.
        """
        arr = numpy.asarray(img.convert("RGBA")).copy()

        # dark = colored pixel (0,0,0) after optimization
        dark_mask = (arr[:, :, 0] == 0) & (arr[:, :, 1] == 0) & (arr[:, :, 2] == 0)

        # Red output pixels
        arr[dark_mask, 0] = 255  # R
        arr[dark_mask, 1] = 0  # G
        arr[dark_mask, 2] = 0  # B

        # Alpha channel
        arr[:, :, 3] = (dark_mask * 255).astype(numpy.uint8)

        return Image.fromarray(arr)

    def get_preview_image(self) -> Image.Image:
        """Returns a black+red preview image, optimized for readability."""

        # 1. Copy black image
        image_black = self.image_black.copy()

        # 2. Optimize the colour layer first (cleans up anti-aliasing)
        optimized_colour = self._optimize_for_red_preview(self.image_colour)

        # 3. Convert darkened layer to red overlay
        image_colour_red = self.color_to_red(optimized_colour)

        # 4. Composite
        image_black.paste(image_colour_red, (0, 0), image_colour_red)

        logger.info("Preview image created (optimized black + red composite)")
        return image_black

auto_fontsize(max_height, sample_text='Ag', target_ratio=0.8)

Automatically scale the canvas' font so its height reaches ~target_ratio of the given max_height.

Parameters:

Name Type Description Default
max_height int

Maximum allowed pixel height.

required
sample_text str

Text used to measure font height. Default "Ag".

'Ag'
target_ratio float

The portion of max_height the font should fill.

0.8

Returns:

Type Description

None — self.font and self._font_size are updated.

Source code in inkycal/utils/canvas.py
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
def auto_fontsize(self, max_height: int, sample_text: str = "Ag", target_ratio: float = 0.80):
    """
    Automatically scale the canvas' font so its height reaches ~target_ratio
    of the given max_height.

    Args:
        max_height (int): Maximum allowed pixel height.
        sample_text (str): Text used to measure font height. Default "Ag".
        target_ratio (float): The portion of max_height the font should fill.

    Returns:
        None — self.font and self._font_size are updated.
    """

    best_size = 1
    target_height = max_height * target_ratio

    # Start from the current size
    size = self._font_size

    # Increment font size until height overshoots target
    while True:
        font = _load_font(self.font_enum.value, size)
        bbox = font.getbbox(sample_text)
        height = bbox[3] - bbox[1]

        if height > target_height:
            break

        best_size = size
        size += 1

    # Load the chosen font size
    self._font_size = best_size
    font_path = self.font_enum.value
    self._font = _load_font(font_path, best_size)

color_to_red(img) staticmethod

Convert dark pixels to red with alpha transparency. Uses optimized thresholding for more accurate previews.

Source code in inkycal/utils/canvas.py
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
@staticmethod
def color_to_red(img: Image.Image) -> Image.Image:
    """
    Convert dark pixels to red with alpha transparency.
    Uses optimized thresholding for more accurate previews.
    """
    arr = numpy.asarray(img.convert("RGBA")).copy()

    # dark = colored pixel (0,0,0) after optimization
    dark_mask = (arr[:, :, 0] == 0) & (arr[:, :, 1] == 0) & (arr[:, :, 2] == 0)

    # Red output pixels
    arr[dark_mask, 0] = 255  # R
    arr[dark_mask, 1] = 0  # G
    arr[dark_mask, 2] = 0  # B

    # Alpha channel
    arr[:, :, 3] = (dark_mask * 255).astype(numpy.uint8)

    return Image.fromarray(arr)

get_line_height(sample_text='Ag')

Return the pixel line height of the currently active font. Based on ascent + descent, with a reliable fallback if unsupported.

Parameters:

Name Type Description Default
sample_text str

A sample string used to measure font height.

'Ag'

Returns:

Type Description
int

int — Line height in pixels.

Source code in inkycal/utils/canvas.py
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
def get_line_height(self, sample_text: str = "Ag") -> int:
    """
    Return the pixel line height of the currently active font.
    Based on ascent + descent, with a reliable fallback if unsupported.

    Args:
        sample_text (str): A sample string used to measure font height.

    Returns:
        int — Line height in pixels.
    """
    try:
        ascent, descent = self._font.getmetrics()
        return ascent + descent
    except Exception:
        # Fallback using bounding box
        bbox = self._font.getbbox(sample_text)
        return int(bbox[3] - bbox[1])

get_preview_image()

Returns a black+red preview image, optimized for readability.

Source code in inkycal/utils/canvas.py
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
def get_preview_image(self) -> Image.Image:
    """Returns a black+red preview image, optimized for readability."""

    # 1. Copy black image
    image_black = self.image_black.copy()

    # 2. Optimize the colour layer first (cleans up anti-aliasing)
    optimized_colour = self._optimize_for_red_preview(self.image_colour)

    # 3. Convert darkened layer to red overlay
    image_colour_red = self.color_to_red(optimized_colour)

    # 4. Composite
    image_black.paste(image_colour_red, (0, 0), image_colour_red)

    logger.info("Preview image created (optimized black + red composite)")
    return image_black

get_text_width(text)

Return the rendered width of a string using the current font.

Parameters:

Name Type Description Default
text str

The text to measure.

required

Returns:

Type Description
int

int — Width in pixels.

Source code in inkycal/utils/canvas.py
274
275
276
277
278
279
280
281
282
283
284
285
def get_text_width(self, text: str) -> int:
    """
    Return the rendered width of a string using the current font.

    Args:
        text (str): The text to measure.

    Returns:
        int — Width in pixels.
    """
    bbox = self._font.getbbox(text)
    return int(bbox[2] - bbox[0])

set_font_size(font_size)

Set the font size to use

Source code in inkycal/utils/canvas.py
29
30
31
def set_font_size(self, font_size: int):
    """Set the font size to use"""
    self._font_size = font_size

text_wrap(text, max_width)

Split long text into wrapped lines using the Canvas' current font.

Parameters:

Name Type Description Default
text str

The full text to wrap.

required
max_width int

Maximum pixel width allowed per line.

required

Returns:

Type Description
list[str]

A list of strings, each representing one wrapped line.

Source code in inkycal/utils/canvas.py
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
def text_wrap(self, text: str, max_width: int) -> list[str]:
    """
    Split long text into wrapped lines using the Canvas' current font.

    Args:
        text: The full text to wrap.
        max_width: Maximum pixel width allowed per line.

    Returns:
        A list of strings, each representing one wrapped line.
    """

    font = self._font
    words = text.split(" ")
    lines = []
    current_line = ""

    for word in words:
        # Test candidate line
        proposed = (current_line + " " + word).strip()

        if font.getlength(proposed) <= max_width:
            # Word fits — extend current line
            current_line = proposed
        else:
            # Word does not fit
            if not current_line:
                # Word itself too long: force-break
                lines.append(word)
            else:
                # Push current line and start a new one
                lines.append(current_line)
                current_line = word

    # Add final line
    if current_line:
        lines.append(current_line)

    return lines

write(xy, box_size, text, *, alignment='center', autofit=False, colour='black', rotation=None, fill_width=1.0, fill_height=0.8)

    Write (possibly multi-line) text inside a rectangle.
    Supports '

' and auto-wrapping inside each line.

Source code in inkycal/utils/canvas.py
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
def write(
        self,
        xy: Tuple[int, int],
        box_size: Tuple[int, int],
        text: str,
        *,
        alignment: Literal["center", "left", "right"] = "center",
        autofit: bool = False,
        colour: Literal["black", "colour"] = "black",
        rotation: Optional[float] = None,
        fill_width: float = 1.0,
        fill_height: float = 0.8,
) -> None:
    """
    Write (possibly multi-line) text inside a rectangle.
    Supports '\n' and auto-wrapping inside each line.
    """

    box_x, box_y = xy
    box_w, box_h = box_size

    font_path = self.font_enum.value
    font = self._font
    size = self._font_size

    # ----------------------------
    # 1) Auto-fit the font size
    # ----------------------------
    if autofit or (fill_width != 1.0) or (fill_height != 0.8):
        size = max(8, size)
        while True:
            font = _load_font(font_path, size)

            # measure test height with 1-line sample
            sample_h = font.getbbox("Ag")[3] - font.getbbox("Ag")[1]

            if sample_h >= int(box_h * fill_height):
                if size > 8:
                    size -= 1
                font = _load_font(font_path, size)
                break

            size += 1

        self._font_size = size
        self._font = font

    # ----------------------------
    # 2) Split text into logical lines
    # ----------------------------
    logical_lines = text.split("\n")

    # ----------------------------
    # 3) Wrap each line to the box width
    # ----------------------------
    wrapped_lines: list[str] = []
    for line in logical_lines:
        wrapped_lines.extend(self.text_wrap(line, max_width=int(box_w * fill_width)))

    if not wrapped_lines:
        return

    # ----------------------------
    # 4) Measure combined height
    # ----------------------------
    line_heights = []
    total_h = 0

    for line in wrapped_lines:
        bbox = font.getbbox(line)
        h = bbox[3] - bbox[1]
        line_heights.append(h)
        total_h += h

    # add minimal line spacing (you can tune this)
    line_spacing = int(font.size * 0.2)
    total_h += line_spacing * (len(wrapped_lines) - 1)

    # If text block too tall → truncate bottom lines
    while total_h > box_h and wrapped_lines:
        removed = wrapped_lines.pop()
        removed_h = line_heights.pop()
        total_h -= (removed_h + line_spacing)

    if not wrapped_lines:
        return

    # ----------------------------
    # 5) Vertical centering
    # ----------------------------
    cy = box_y + (box_h - total_h) // 2

    # ----------------------------
    # 6) Create transparent layer and draw lines
    # ----------------------------
    space = Image.new("RGBA", (box_w, box_h), (0, 0, 0, 0))
    draw = ImageDraw.Draw(space)

    py = 0
    for line, lh in zip(wrapped_lines, line_heights):

        # horizontal alignment
        line_w = font.getbbox(line)[2] - font.getbbox(line)[0]

        if alignment == "center":
            px = (box_w - line_w) // 2
        elif alignment == "left":
            px = 0
        elif alignment == "right":
            px = box_w - line_w
        else:
            px = (box_w - line_w) // 2

        draw.text((px, py), line, fill="black", font=font)
        py += lh + line_spacing

    # ----------------------------
    # 7) Rotation + paste
    # ----------------------------
    if rotation:
        space = space.rotate(rotation, expand=True)

    # Always draw on black first
    self.image_black.paste(space, xy, space)

    # Colour overlay if needed
    if colour == "colour":
        self.image_colour.paste(space, xy, space)

🔧 Utility Functions

General Utils

Utility Functions for Inkycal

This module contains small standalone helpers used throughout the Inkycal framework. These functions handle tasks such as timezone detection, network availability checks, simple drawing helpers, and generating lightweight charts.

These utilities are intentionally framework-agnostic and can be used inside modules, during setup, or anywhere Inkycal requires common functionality.

draw_border(image, xy, size, radius=5, thickness=1, shrinkage=(0.1, 0.1))

Draw a stylized border around a rectangular region.

Parameters:

Name Type Description Default
image Image

The PIL image into which the border is drawn.

required
xy Tuple[int, int]

Top-left corner of the border (x, y).

required
size Tuple[int, int]

Width and height of the border before shrinkage is applied.

required
radius int

Corner roundness. 0 creates a rectangle with sharp corners.

5
thickness int

Stroke width in pixels.

1
shrinkage Tuple[float, float]

Proportional shrinkage of width and height. For example: (0.1, 0.2) → shrink width by 10% and height by 20%.

(0.1, 0.1)
Notes

This function is used by various modules (Calendar, Agenda, Feeds) to visually highlight areas such as days with events.

Source code in inkycal/utils/functions.py
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
def draw_border(image: Image.Image, xy: Tuple[int, int], size: Tuple[int, int], radius: int = 5, thickness: int = 1,
        shrinkage: Tuple[float, float] = (0.1, 0.1), ) -> None:
    """Draw a stylized border around a rectangular region.

    Args:
        image (PIL.Image.Image):
            The PIL image into which the border is drawn.

        xy (Tuple[int, int]):
            Top-left corner of the border (x, y).

        size (Tuple[int, int]):
            Width and height of the border before shrinkage is applied.

        radius (int, optional):
            Corner roundness. ``0`` creates a rectangle with sharp corners.

        thickness (int, optional):
            Stroke width in pixels.

        shrinkage (Tuple[float, float], optional):
            Proportional shrinkage of width and height. For example:
            ``(0.1, 0.2)`` → shrink width by 10% and height by 20%.

    Notes:
        This function is used by various modules (Calendar, Agenda, Feeds)
        to visually highlight areas such as days with events.
    """

    colour = "black"

    # Apply shrinkage to box size
    width = int(size[0] * (1 - shrinkage[0]))
    height = int(size[1] * (1 - shrinkage[1]))

    # Center correction
    offset_x = int((size[0] - width) / 2)
    offset_y = int((size[1] - height) / 2)

    x = xy[0] + offset_x
    y = xy[1] + offset_y
    diameter = radius * 2

    # Core rectangle parameters
    a = width - diameter
    b = height - diameter

    # Straight line segments
    p1, p2 = (x + radius, y), (x + radius + a, y)
    p3, p4 = (x + width, y + radius), (x + width, y + radius + b)
    p5, p6 = (p2[0], y + height), (p1[0], y + height)
    p7, p8 = (x, p4[1]), (x, p3[1])

    draw = ImageDraw.Draw(image)
    draw.line((p1, p2), fill=colour, width=thickness)
    draw.line((p3, p4), fill=colour, width=thickness)
    draw.line((p5, p6), fill=colour, width=thickness)
    draw.line((p7, p8), fill=colour, width=thickness)

    # Rounded corners
    if radius > 0:
        c1, c2 = (x, y), (x + diameter, y + diameter)
        c3, c4 = (x + width - diameter, y), (x + width, y + diameter)
        c5, c6 = (x + width - diameter, y + height - diameter), (x + width, y + height)
        c7, c8 = (x, y + height - diameter), (x + diameter, y + height)

        draw.arc((c1, c2), 180, 270, fill=colour, width=thickness)
        draw.arc((c3, c4), 270, 360, fill=colour, width=thickness)
        draw.arc((c5, c6), 0, 90, fill=colour, width=thickness)
        draw.arc((c7, c8), 90, 180, fill=colour, width=thickness)

draw_border_2(im, xy, size, radius)

Draw a simple rounded rectangle border using Pillow's high-level API.

Source code in inkycal/utils/functions.py
167
168
169
170
171
172
def draw_border_2(im: Image.Image, xy: Tuple[int, int], size: Tuple[int, int], radius: int):
    """Draw a simple rounded rectangle border using Pillow's high-level API."""
    draw = ImageDraw.Draw(im)
    x, y = xy
    w, h = size
    draw.rounded_rectangle((x, y, x + w, y + h), outline="black", radius=radius)

get_inkycal_version()

Resolve Inkycal version.

Priority: 1. Installed package metadata (preferred, matches pyproject.toml) 2. _version.txt inside the package (editable / dev fallback)

Source code in inkycal/utils/functions.py
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
def get_inkycal_version() -> str:
    """
    Resolve Inkycal version.

    Priority:
    1. Installed package metadata (preferred, matches pyproject.toml)
    2. _version.txt inside the package (editable / dev fallback)
    """
    try:
        return pkg_version("inkycal")
    except PackageNotFoundError:
        try:
            version_file = files("inkycal").joinpath("_version.txt")
            return version_file.read_text().strip()
        except Exception:
            return "unknown"

get_system_tz()

Return the system's timezone as a string.

Attempts to detect the local timezone using tzlocal. If detection fails, the function falls back to "UTC" and logs a warning.

Returns:

Name Type Description
str str

The detected timezone name. Examples include: - "Europe/Berlin" - "America/New_York" - "UTC" (fallback)

Example

arrow.now(tz=get_system_tz())

Source code in inkycal/utils/functions.py
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
def get_system_tz() -> str:
    """Return the system's timezone as a string.

    Attempts to detect the local timezone using ``tzlocal``. If detection fails,
    the function falls back to ``"UTC"`` and logs a warning.

    Returns:
        str: The detected timezone name. Examples include:
            - ``"Europe/Berlin"``
            - ``"America/New_York"``
            - ``"UTC"`` (fallback)

    Example:
        >>> arrow.now(tz=get_system_tz())
        <Arrow [2025-02-18T12:34:56+01:00]>
    """
    try:
        local_tz = tzlocal.get_localzone().key
        logger.debug(f"Local system timezone is {local_tz}.")
    except Exception:
        logger.error("System timezone could not be parsed! Falling back to UTC.")
        local_tz = "UTC"

    # Log formatted current time in detected TZ
    logger.debug(f"Current time: {arrow.now(tz=local_tz).format('YYYY-MM-DD HH:mm:ss ZZ')}")
    return local_tz

internet_available()

Check whether the internet connection is reachable.

The function attempts 3 connections to https://google.com with a short timeout. If any request succeeds, the network is considered available.

Returns:

Name Type Description
bool bool

True if at least one connection attempt succeeds, otherwise

bool

False.

Example

if internet_available(): ... print("Online!") ... else: ... print("Offline!")

Source code in inkycal/utils/functions.py
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
def internet_available() -> bool:
    """Check whether the internet connection is reachable.

    The function attempts 3 connections to ``https://google.com`` with a short
    timeout. If any request succeeds, the network is considered available.

    Returns:
        bool: ``True`` if at least one connection attempt succeeds, otherwise
        ``False``.

    Example:
        >>> if internet_available():
        ...     print("Online!")
        ... else:
        ...     print("Offline!")
    """
    for attempt in range(3):
        try:
            requests.get("https://google.com", timeout=5)
            return True
        except Exception:
            print(f"Network could not be reached: {traceback.print_exc()}")
            time.sleep(5)

    return False

render_line_chart(values, size, line_width=2, line_color='black', bg_color='white', padding=4)

Render a lightweight line chart using Pillow.

Parameters:

Name Type Description Default
values Sequence[float]

A list/tuple of numeric values to plot. Must contain at least 2 points to draw a line.

required
size Tuple[int, int]

Output image size (width, height).

required
line_width int

Thickness of the plotted line.

2
line_color str or tuple

Colour of the line. Accepts any Pillow colour value.

'black'
bg_color str or tuple

Background colour.

'white'
padding int

Inner padding in pixels (space to chart edges).

4

Returns:

Type Description
Image

PIL.Image.Image: A new image containing the rendered chart.

Notes
  • Scaling is automatically normalized between min(values) and max(values).
  • Used primarily by the Stocks module.
Example

img = render_line_chart([1, 3, 2, 5], (200, 80)) img.show()

Source code in inkycal/utils/functions.py
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
def render_line_chart(values: Sequence[float], size: Tuple[int, int], line_width: int = 2, line_color="black",
        bg_color="white", padding: int = 4) -> Image.Image:
    """Render a lightweight line chart using Pillow.

    Args:
        values (Sequence[float]):
            A list/tuple of numeric values to plot. Must contain at least 2
            points to draw a line.

        size (Tuple[int, int]):
            Output image size ``(width, height)``.

        line_width (int, optional):
            Thickness of the plotted line.

        line_color (str or tuple, optional):
            Colour of the line. Accepts any Pillow colour value.

        bg_color (str or tuple, optional):
            Background colour.

        padding (int, optional):
            Inner padding in pixels (space to chart edges).

    Returns:
        PIL.Image.Image:
            A new image containing the rendered chart.

    Notes:
        - Scaling is automatically normalized between min(values) and
          max(values).
        - Used primarily by the Stocks module.

    Example:
        >>> img = render_line_chart([1, 3, 2, 5], (200, 80))
        >>> img.show()
    """

    width, height = size
    img = Image.new("RGBA", (width, height), bg_color)
    draw = ImageDraw.Draw(img)

    if not values or len(values) < 2:
        return img  # nothing to draw

    v_min = min(values)
    v_max = max(values)

    # Avoid division by zero for flat datasets
    if math.isclose(v_min, v_max):
        v_min -= 1.0
        v_max += 1.0

    inner_w = max(1, width - 2 * padding)
    inner_h = max(1, height - 2 * padding)

    def to_xy(idx: int, val: float, n: int):
        """Transform a value into canvas coordinates."""
        # X: evenly spaced
        x = padding + (inner_w * idx) / (n - 1) if n > 1 else padding + inner_w / 2

        # Y: inverted because (0,0) is top-left
        norm = (val - v_min) / (v_max - v_min)
        y = padding + inner_h * (1.0 - norm)

        return x, y

    n = len(values)
    pts = [to_xy(i, float(v), n) for i, v in enumerate(values)]

    draw.line(pts, fill=line_color, width=line_width)

    return img

Enumerations (fonts, settings)

enums.py

Supported Display Models


🏁 Summary

This API reference is meant as a central index for:

  • All modules
  • Rendering engine (Canvas)
  • ePaper Display driver
  • Utilities

If you are developing your own module, be sure to check:

  • template.py
  • Developer Guide (dev_doc.md)
  • Canvas documentation (canvas.md)