diff --git a/inkycal/modules/__init__.py b/inkycal/modules/__init__.py index b1f2e8a..4d71e74 100644 --- a/inkycal/modules/__init__.py +++ b/inkycal/modules/__init__.py @@ -13,3 +13,4 @@ from .inkycal_xkcd import Xkcd from .inkycal_fullweather import Fullweather from .inkycal_tindie import Tindie from .inkycal_vikunja import Vikunja +from .inkycal_today import Today diff --git a/inkycal/modules/inkycal_fullweather.py b/inkycal/modules/inkycal_fullweather.py index 5dccc70..0fc609f 100644 --- a/inkycal/modules/inkycal_fullweather.py +++ b/inkycal/modules/inkycal_fullweather.py @@ -19,6 +19,11 @@ from PIL import ImageDraw from PIL import ImageFont from PIL import ImageOps +import sys +project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +if project_root not in sys.path: + sys.path.insert(0, project_root) + from icons.weather_icons.weather_icons import get_weather_icon from inkycal.custom.functions import fonts from inkycal.custom.functions import get_system_tz diff --git a/inkycal/modules/inkycal_today.py b/inkycal/modules/inkycal_today.py new file mode 100644 index 0000000..0312cde --- /dev/null +++ b/inkycal/modules/inkycal_today.py @@ -0,0 +1,328 @@ +""" +Inkycal Calendar Module +Copyright by aceinnolab +""" + +# pylint: disable=logging-fstring-interpolation + +import calendar as cal + +from inkycal.custom import * +from inkycal.modules.template import inkycal_module + +logger = logging.getLogger(__name__) + +def get_ip_address(): + """Get public IP address from external service.""" + try: + # 方法1: 使用 ipify.org + response = requests.get('https://api.ipify.org?format=json', timeout=5) + return response.json()['ip'] + except Exception: + try: + # 方法2: 使用 icanhazip.com (备用) + response = requests.get('https://icanhazip.com', timeout=5) + return response.text.strip() + except Exception: + try: + # 方法3: 使用 ifconfig.me (备用) + response = requests.get('https://ifconfig.me/ip', timeout=5) + return response.text.strip() + except Exception: + return "N/A" + +class Today(inkycal_module): + """today class + Show today's date and events from given iCalendars + """ + + name = "Today - Show today's date and 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 = ImageFont.truetype( + fonts['NotoSans-SemiCondensed'], size=self.fontsize + ) + + # 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 the image for today's date and events. """ + + # **************************************************************************************************************** + # Create base image + + # Define new image size with respect to padding + im_width = self.width - 2 * self.padding_left + im_height = self.height - 2 * self.padding_top + im_size = (im_width, im_height) + + event_height = 0 + + logger.debug(f'Generating Today module image of size {im_size}') + + # Create an iamge for black and colour Inky displays + im_black = Image.new('RGB', im_size, color='white') + im_colour = Image.new('RGB', im_size, color='white') + + # Split the image into two sections: date section and events section + left_section_width = int(im_width * 0.2) + right_section_width = im_width - left_section_width + + # 5% bottom space will be reserved for show the day progress bar + left_section = (0, 0, left_section_width, im_height - int(im_height * 0.05)) + right_section = (left_section_width, 0, im_width, im_height - int(im_height * 0.05)) + + section_height = left_section[3] + + + # **************************************************************************************************************** + # Edit left section - show today's date + now = arrow.now(tz=self.timezone) + month_height = int(im_height * 0.15) + month_font = ImageFont.truetype( + fonts['NotoSans-SemiCondensed'], size=int(self.fontsize * 1.5) + ) + write( + im_black, + (0, 0), + (left_section_width, month_height), + now.format('MMMM', locale=self.language), + font=month_font, + autofit=False + ) + + date_height = int(im_height * 0.5) + date_y = month_height + large_font = ImageFont.truetype( + fonts['NotoSans-SemiCondensed'], size=int(self.fontsize * 4) + ) + date_time = arrow.now() + day = date_time.day + print(str(day)) + write( + im_colour, + (0, date_y), + (left_section_width, date_height), + str(day), + font=large_font, + autofit=False + ) + + weekday_y = month_height + date_height + weekday_height = int(im_height * 0.15) + weekday_font = ImageFont.truetype( + fonts['NotoSans-SemiCondensed'], size=int(self.fontsize * 2) + ) + write( + im_black, + (0, weekday_y), + (left_section_width, weekday_height), + now.format('dddd', locale=self.language), + font=weekday_font, + autofit=False + ) + + # show IP address at the bottom left + ip_y = weekday_y + weekday_height + ip_height = im_height - ip_y - 5 + ip_address = get_ip_address() + write( + im_black, + (0, ip_y), + (left_section_width, ip_height), + ip_address, + font=self.font, + alignment='center', + autofit=True + ) + + # **************************************************************************************************************** + # Draw a dash line to separate left and right sections + for _y in range(0, section_height, 8): + + ImageDraw.Draw(im_black).line( + [(left_section_width, _y), (left_section_width, _y + 4)], + fill='black', + width=2, + ) + + # **************************************************************************************************************** + # Edit right section - show today's events + if self.show_events: + # 导入日历解析器 + from inkycal.modules.ical_parser import iCalendar + + parser = iCalendar() + + if self.ical_urls: + parser.load_url(self.ical_urls) + if self.ical_files: + parser.load_from_file(self.ical_files) + + # 获取今天的事件 + today_start = now.floor('day') + today_end = now.ceil('day') + upcoming_events = parser.get_events(today_start, today_end, self.timezone) + + # 计算右侧可用空间 + right_x = left_section_width + 5 # 留5px边距 + right_usable_width = right_section_width - 10 # 左右各留5px + + # 计算行高 + line_spacing = 2 + text_bbox = self.font.getbbox("hg") + line_height = text_bbox[3] + line_spacing + max_lines = im_height // line_height + + if upcoming_events: + # 显示事件 + cursor = 0 + for event in upcoming_events[:max_lines]: + if cursor >= max_lines: + break + + y_pos = cursor * line_height + + # 显示时间 + time_str = event['begin'].format(self.time_format, locale=self.language) + time_width = int(self.font.getlength(time_str) * 1.1) + + write( + im_black, + (right_x, y_pos), + (time_width, line_height), + time_str, + font=self.font, + alignment='left' + ) + + # 显示事件标题 + event_x = right_x + time_width + 5 + event_width = right_usable_width - time_width - 5 + + write( + im_black, + (event_x, y_pos), + (event_width, line_height), + event['title'], + font=self.font, + alignment='left' + ) + + cursor += 1 + else: + # 没有事件时显示提示 + write( + im_black, + (right_x, int(im_height / 2)), + (right_usable_width, line_height), + "No events today", + font=self.font, + alignment='center' + ) + + # **************************************************************************************************************** + # Draw progress bar at the bottom (24 segments for 24 hours) + progress_bar_height = int(im_height * 0.05) + progress_bar_y = im_height - progress_bar_height + + # 计算当前小时进度 + current_hour = now.hour + current_minute = now.minute + current_progress = current_hour + (current_minute / 60.0) # 0-24 的浮点数 + + # 绘制24个格子 + num_segments = 24 + segment_spacing = 2 # 格子之间的间距 + total_spacing = segment_spacing * (num_segments - 1) + segment_width = (im_width - total_spacing) / num_segments + + draw = ImageDraw.Draw(im_black) + + for i in range(num_segments): + # 计算每个格子的位置 + x_start = int(i * (segment_width + segment_spacing)) + x_end = int(x_start + segment_width) + + # 判断该格子是否已完成 + if i < current_progress: + # 已完成的格子填充黑色(在 im_colour 上会显示为红色) + draw.rectangle( + [(x_start, progress_bar_y), (x_end, im_height)], + fill='black', + outline='black' + ) + else: + # 未完成的格子只画边框(在 im_black 上画) + ImageDraw.Draw(im_black).rectangle( + [(x_start, progress_bar_y), (x_end, im_height)], + fill='white', + outline='black', + width=1 + ) + + return im_black, im_colour diff --git a/tests/config.py b/tests/config.py index 56b37bd..6d3448d 100644 --- a/tests/config.py +++ b/tests/config.py @@ -36,6 +36,8 @@ class Config: TINDIE_API_KEY = get("TINDIE_API_KEY") TINDIE_USERNAME = get("TINDIE_USERNAME") + OUTPUT_DIR = f"{basedir}/../image_folder" + diff --git a/tests/settings.json b/tests/settings.json index b8aa52a..4dad152 100644 --- a/tests/settings.json +++ b/tests/settings.json @@ -18,7 +18,7 @@ }, { "position": 2, - "name": "Calendar", + "name": "Today", "config": { "size": [ 528, diff --git a/tests/test_inkycal_today.py b/tests/test_inkycal_today.py new file mode 100644 index 0000000..90e9e08 --- /dev/null +++ b/tests/test_inkycal_today.py @@ -0,0 +1,83 @@ +""" +inkycal_today unittest +""" +import logging +import unittest + +from inkycal.modules import Today +from inkycal.modules.inky_image import Inkyimage, image_to_palette +from tests import Config + +merge = Inkyimage.merge + +sample_url = Config.SAMPLE_ICAL_URL + +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.DEBUG) + +# /workspaces/Inkycal/venv/bin/python3 -m pytest tests/test_inkycal_today.py::TestToday::test_generate_image -v -s +tests = [ + { + "name": "Today", + "config": { + "size": [528, 343], + "week_starts_on": "Monday", + "show_events": True, + "ical_urls": sample_url, + "ical_files": None, + "date_format": "D MMM", "time_format": "HH:mm", + "padding_x": 10, "padding_y": 10, "fontsize": 12, "language": "en" + } + }, +] + + +class TestToday(unittest.TestCase): + + def test_generate_image(self): + output_dir = Config.OUTPUT_DIR + import os + from PIL import Image, ImageDraw + import numpy as np + + os.makedirs(output_dir, exist_ok=True) + test_num = 0 + for test in tests: + print(f'test {tests.index(test) + 1} generating image..', end="") + module = Today(test) + im_black, im_colour = module.generate_image() + print('OK') + + # 创建最终的三色图像 + # 在 E-Paper 上: im_black 的黑色像素显示黑色, im_colour 的黑色像素显示红色 + result = Image.new('RGB', im_black.size, 'white') + result_array = np.array(result) + black_array = np.array(im_black) + colour_array = np.array(im_colour) + + # 使用阈值处理抗锯齿:灰度值 < 128 的视为"黑色" + # im_black 上的深色区域 -> 黑色 (0, 0, 0) + black_gray = black_array[:,:,0] # 取灰度值(RGB相同) + black_mask = black_gray < 128 + result_array[black_mask] = [0, 0, 0] + + # im_colour 上的深色区域 -> 红色 (255, 0, 0) + colour_gray = colour_array[:,:,0] + colour_mask = colour_gray < 128 + result_array[colour_mask] = [255, 0, 0] + + # 保存最终图像 + final_image = Image.fromarray(result_array) + output_path = os.path.join(output_dir, f"today_test_{test_num}.png") + final_image.save(output_path) + print(f' -> Saved to {output_path}') + + # 统计颜色 + red_count = np.sum(colour_mask) + black_count = np.sum(black_mask) + print(f' 🔴 Red pixels: {red_count}, ⚫ Black pixels: {black_count}') + + test_num += 1 + + if Config.USE_PREVIEW: + final_image.show() \ No newline at end of file