diff --git a/Raspberry_Pi_Severance_MDR_Terminal/data.py b/Raspberry_Pi_Severance_MDR_Terminal/data.py new file mode 100644 index 000000000..6c4070dce --- /dev/null +++ b/Raspberry_Pi_Severance_MDR_Terminal/data.py @@ -0,0 +1,300 @@ +# SPDX-FileCopyrightText: 2025 Liz Clark for Adafruit Industries +# SPDX-License-Identifier: MIT + +import math +import random +import tkinter as tk +import time +from palette import Palette + +class DataNumber: + active_bin = None + + @classmethod + def reset_active_bin(cls): + """Reset the class-level active bin tracker""" + cls.active_bin = None + + def __init__(self, x: int, y: int, canvas: tk.Canvas, base_size: int = 35, palette=Palette): + """ + Initialize a data number for macrodata refinement + """ + self.num = random.randint(0, 9) + self.home_x = x + self.home_y = y + self.x = x + self.y = y + self.mouse_offset_x = 0 + self.mouse_offset_y = 0 + self.palette = palette + self.color = self.palette.FG + self.alpha = 255 + self.base_size = base_size + self.size = base_size + self.refined = False + self.bin_it = False + self.bin = None + self.bin_pause_time = 2 + self.bin_pause = self.bin_pause_time + self.canvas = canvas + self.text_id = self.canvas.create_text( + self.x, self.y, + text=str(self.num), + font=('Courier', self.size), + fill=self.color, + anchor='center' + ) + self.needs_refinement = False + self.wiggle_offset_x = 0 + self.wiggle_offset_y = 0 + + def refine(self, bin_obj=None, bins_list=None): + """ + Mark this number for refinement and assign it to a bin. + """ + if bin_obj is not None: + if bin_obj.is_full(): + return False + target_bin = bin_obj + elif bins_list is not None: + target_bin = self.get_non_full_bin_for_position(bins_list) + if target_bin is None: + return False + else: + raise ValueError("Either bin_obj or bins_list must be provided") + self.bin_it = True + if DataNumber.active_bin is None: + DataNumber.active_bin = target_bin + self.bin = target_bin + else: + if DataNumber.active_bin.is_full(): + DataNumber.active_bin = target_bin + self.bin = DataNumber.active_bin + return True + + def get_non_full_bin_for_position(self, bins_list): + """ + Determine which available bin should open based on the position of this number. + """ + non_full_bins = [bin_obj for bin_obj in bins_list if not bin_obj.is_full()] + if not non_full_bins: + return None + screen_width = self.canvas.winfo_width() + original_bin_index = self.get_bin_index_for_position(screen_width, len(bins_list)) + closest_bin = None + min_distance = float('inf') + for bin_obj in non_full_bins: + distance = abs(bin_obj.i - original_bin_index) + if distance < min_distance: + min_distance = distance + closest_bin = bin_obj + return closest_bin + + def get_bin_index_for_position(self, screen_width, num_bins): + """ + Get the bin index that corresponds to this number's position + """ + bin_width = screen_width / num_bins + bin_index = int(self.x / bin_width) + bin_index = max(0, min(bin_index, num_bins - 1)) + return bin_index + + def go_bin(self): + """Move toward the bin for refinement""" + if self.bin: + self.bin.open() + if self.bin_pause <= 0: + dx = self.bin.x - self.x + dy = self.bin.y - self.y + distance = math.sqrt(dx*dx + dy*dy) + if distance < 20: + self.alpha = int(255 * (distance / 20)) + if distance < 3: + self.wiggle_offset_x = 0 + self.wiggle_offset_y = 0 + self.mouse_offset_x = 0 + self.mouse_offset_y = 0 + self.bin.add_number() + self.reset() + return + easing = max(0.03, min(0.1, 5.0 / distance)) + self.x += dx * easing + self.y += dy * easing + fade_start_distance = self.distance(self.home_x, self.home_y, + self.bin.x, self.bin.y) * 0.4 + current_distance = self.distance(self.x, self.y, self.bin.x, self.bin.y) + if distance >= 20: + self.alpha = self.map_value(current_distance, fade_start_distance, 20, 255, 55) + self.update_display() + if hasattr(self.bin, 'level_elements'): + for element_id in self.bin.level_elements.values(): + self.canvas.tag_raise(self.text_id, element_id) + self.bin.last_refined_time = int(time.time() * 1000) + else: + self.bin_pause -= 1 + if self.bin_pause > 0: + pulse_size = self.base_size * (1.0 + 0.5 * + (1.0 - (self.bin_pause / self.bin_pause_time))) + self.set_size(pulse_size) + if hasattr(self.bin, 'level_elements'): + for element_id in self.bin.level_elements.values(): + self.canvas.tag_raise(self.text_id, element_id) + + def reset(self): + """Reset the number after being binned.""" + self.num = random.randint(0, 9) + self.x = self.home_x + self.y = self.home_y + self.wiggle_offset_x = 0 + self.wiggle_offset_y = 0 + self.mouse_offset_x = 0 + self.mouse_offset_y = 0 + self.refined = False + self.bin_it = False + self.bin = None + self.color = self.palette.FG + self.alpha = 255 + self.bin_pause = self.bin_pause_time + self.update_display() + still_active = False + if not still_active and DataNumber.active_bin is not None: + DataNumber.active_bin = None + + def go_home(self): + """Move the number back to its home position with easing.""" + self.x = self.lerp(self.x, self.home_x, 0.1) + self.y = self.lerp(self.y, self.home_y, 0.1) + self.size = self.lerp(self.size, self.base_size, 0.1) + self.update_display() + + def set_size(self, sz): + """Set the size of the number.""" + self.size = sz + self.update_display() + + def turn(self, new_color): + """Change the color of the number.""" + self.color = new_color + self.update_display() + + def inside(self, x1, y1, x2, y2): + """Check if this number is inside the given rectangle.""" + return ( + self.x > min(x1, x2) and + self.x < max(x1, x2) and + self.y > min(y1, y2) and + self.y < max(y1, y2) + ) + + def show(self): + """Update the display of this number.""" + self.update_display() + + def update_display(self): + """Update the text display with current properties and improved alpha handling""" + if self.bin_it: + digit_size = self.lerp(self.size, self.size * 2.5, + self.map_value(self.bin_pause, self.bin_pause_time, 0, 0, 1)) + else: + digit_size = self.size + font = ('Courier', int(digit_size)) + clamped_alpha = max(0, min(255, self.alpha)) + if clamped_alpha == 0: + self.canvas.itemconfig(self.text_id, state='hidden') + return + else: + self.canvas.itemconfig(self.text_id, state='normal') + if clamped_alpha < 255: + bg_color = self.palette.BG + fg_color = self.color + alpha_ratio = clamped_alpha / 255.0 + if alpha_ratio < 0.05: + display_color = self.blend_colors(bg_color, fg_color, 0.05) + else: + display_color = self.blend_colors(bg_color, fg_color, alpha_ratio) + else: + display_color = self.color + self.canvas.itemconfig(self.text_id, + text=str(self.num), + font=font, + fill=display_color) + if not hasattr(self, 'wiggle_offset_x'): + self.wiggle_offset_x = 0 + if not hasattr(self, 'wiggle_offset_y'): + self.wiggle_offset_y = 0 + if not hasattr(self, 'mouse_offset_x'): + self.mouse_offset_x = 0 + if not hasattr(self, 'mouse_offset_y'): + self.mouse_offset_y = 0 + smooth_wiggle_x = round(self.wiggle_offset_x * 10) / 10 + smooth_wiggle_y = round(self.wiggle_offset_y * 10) / 10 + smooth_mouse_x = round(self.mouse_offset_x * 10) / 10 + smooth_mouse_y = round(self.mouse_offset_y * 10) / 10 + display_x = self.x + smooth_wiggle_x + smooth_mouse_x + display_y = self.y + smooth_wiggle_y + smooth_mouse_y + self.canvas.coords(self.text_id, display_x, display_y) + + def resize(self, new_x, new_y): + """Update the home position when the window is resized.""" + self.home_x = new_x + self.home_y = new_y + + def show_wiggle(self, proximity_factor=0): + """Make the number threatening""" + if self.needs_refinement and not self.bin_it: + original_x, original_y = self.x, self.y + smooth_x = round(self.wiggle_offset_x * 10) / 10 + smooth_y = round(self.wiggle_offset_y * 10) / 10 + self.x += smooth_x + self.y += smooth_y + original_color = self.color + base_pulse = 0.7 + wave1 = math.sin(time.time() * 0.9) * 0.15 + wave2 = math.sin(time.time() * 1.8) * 0.05 + highlight_intensity = base_pulse + wave1 + wave2 + (proximity_factor * 0.2) + highlight_intensity = max(0.6, min(1.0, highlight_intensity)) + if highlight_intensity > 0.82: + self.color = self.palette.SELECT + else: + blend_amount = (highlight_intensity - 0.6) / 0.22 + self.color = self.blend_colors(self.palette.FG, self.palette.SELECT, blend_amount) + self.update_display() + self.x, self.y = original_x, original_y + self.color = original_color + + @staticmethod + def lerp(start, end, amt): + """Linear interpolation between start and end by amt.""" + return start + (end - start) * amt + + @staticmethod + def map_value(value, start1, stop1, start2, stop2): + """Re-maps a number from one range to another.""" + if stop1 == start1: + return start2 + return start2 + (stop2 - start2) * ((value - start1) / (stop1 - start1)) + + @staticmethod + def distance(x1, y1, x2, y2): + """Calculate distance between two points.""" + return math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) + + @staticmethod + def hex_to_rgb(hex_color): + """Convert hex color to RGB values.""" + # Strip the # if it exists + hex_color = hex_color.lstrip('#') + return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) + + @staticmethod + def rgb_to_hex(rgb): + """Convert RGB tuple to hex color.""" + return f"#{rgb[0]:02x}{rgb[1]:02x}{rgb[2]:02x}" + + @staticmethod + def blend_colors(color1, color2, ratio): + """Blend two colors based on ratio (0-1).""" + c1 = DataNumber.hex_to_rgb(color1) + c2 = DataNumber.hex_to_rgb(color2) + blended = tuple(int(c1[i] + (c2[i] - c1[i]) * ratio) for i in range(3)) + return DataNumber.rgb_to_hex(blended) diff --git a/Raspberry_Pi_Severance_MDR_Terminal/data_bin.py b/Raspberry_Pi_Severance_MDR_Terminal/data_bin.py new file mode 100644 index 000000000..ed7f9a788 --- /dev/null +++ b/Raspberry_Pi_Severance_MDR_Terminal/data_bin.py @@ -0,0 +1,417 @@ +# SPDX-FileCopyrightText: 2025 Liz Clark for Adafruit Industries +# SPDX-License-Identifier: MIT + +import math +import time +import random +import tkinter as tk +from PIL import Image, ImageTk, ImageFont, ImageDraw + +# pylint: disable=broad-except,too-many-locals + +class Bin: + KEYS = ['WO', 'FC', 'DR', 'MA'] + MAX_LID_ANGLE = 45 + CLOSED_LID_ANGLE = 180 + MAX_SHOW_TIME = 1500 # milliseconds + LID_OPEN_CLOSE_TIME = 750 # milliseconds + + def __init__(self, width, index, goal, canvas, levels=None, palette=None): + self.w = width + self.i = index + self.x = index * width + width * 0.5 + self.canvas = canvas + self.buffer = 60 + self.y = canvas.winfo_height() - 50 + self.palette = palette + self.fg_color = self.palette.FG + self.bg_color = self.palette.BG + self.goal = goal + self.level_goal = self.goal / 4 + self.level_h = self.buffer * 1.7 + self.levels_y_offset = self.level_h + self.last_refined_time = self.get_millis() + if levels is None: + self.levels = { + 'WO': 0, + 'FC': 0, + 'DR': 0, + 'MA': 0 + } + else: + self.levels = levels + self.count = sum(self.levels.values()) + + self.show_levels = False + self.closing_animation = False + self.opening_animation = False + self.lid_angle = self.CLOSED_LID_ANGLE + self.show_time = 0 + self.animation_start_time = 0 + self.animation_progress = 0 + + self.visual_elements = {} + self.level_elements = {} + self.progress_bar_elements = {} + + self.create_visual_elements() + + def create_outlined_text(self, text, font_size=22, stroke_width=4): + font = ImageFont.truetype("/usr/share/fonts/truetype/msttcorefonts/arial.ttf", font_size) + dummy_img = Image.new("RGBA", (1, 1), (0, 0, 0, 0)) + dummy_draw = ImageDraw.Draw(dummy_img) + bbox = dummy_draw.textbbox((0, 0), text, font=font) + text_width = bbox[2] - bbox[0] + text_height = bbox[3] - bbox[1] + padding = 12 + width = text_width + padding * 2 + stroke_width * 2 + height = text_height + padding * 2 + stroke_width * 2 + img = Image.new("RGBA", (width, height), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + fill_color = self.bg_color + stroke_color = self.fg_color + position = (padding + stroke_width, padding+stroke_width) + draw.text(position, text, font=font, fill=fill_color, + stroke_width=stroke_width, stroke_fill=stroke_color) + photo = ImageTk.PhotoImage(img) + return photo + + def create_visual_elements(self): + for key in list(self.visual_elements.keys()): + try: + self.canvas.delete(self.visual_elements[key]) + except Exception: + pass + for key in list(self.level_elements.keys()): + try: + self.canvas.delete(self.level_elements[key]) + except Exception: + pass + for key in list(self.progress_bar_elements.keys()): + try: + self.canvas.delete(self.progress_bar_elements[key]) + except Exception: + pass + self.visual_elements = {} + self.level_elements = {} + self.progress_bar_elements = {} + rw = self.w + popup_width = rw + popup_height = self.buffer * 3 + base_y = self.y - self.buffer/4 + popup_y = base_y - popup_height/2 + self.visual_elements['main'] = self.canvas.create_rectangle( + self.x - rw/2, self.y - self.buffer/4, + self.x + rw/2, self.y + self.buffer/4, + outline=self.fg_color, fill=self.bg_color, width=1 + ) + self.visual_elements['label'] = self.canvas.create_text( + self.x, self.y, + text=f"{self.i:02d}", + font=('Arial', 16), + fill=self.fg_color + ) + self.level_elements['popup_bg'] = self.canvas.create_rectangle( + self.x - popup_width/2, popup_y - popup_height/2, + self.x + popup_width/2, popup_y + popup_height/2, + outline="", fill=self.bg_color, + state='hidden' + ) + self.level_elements['container'] = self.canvas.create_rectangle( + self.x - popup_width/2, popup_y - popup_height/2, + self.x + popup_width/2, popup_y + popup_height/2, + outline=self.fg_color, fill="", width=1, + state='hidden' + ) + bar_height = popup_height * 0.15 + for i, key in enumerate(self.KEYS): + level_y = popup_y - popup_height * 0.3 + i * (popup_height * 0.6 / 3) + self.level_elements[f'{key}_label'] = self.canvas.create_text( + self.x - popup_width * 0.4, level_y, + text=key, + font=('Courier', 14), + fill=self.fg_color, + anchor='w', + state='hidden' + ) + self.level_elements[f'{key}_outline'] = self.canvas.create_rectangle( + self.x - popup_width * 0.2, level_y - bar_height/2, + self.x + popup_width * 0.4, level_y + bar_height/2, + outline=self.fg_color, fill="", + state='hidden' + ) + self.level_elements[f'{key}_progress'] = self.canvas.create_rectangle( + self.x - popup_width * 0.2, level_y - bar_height/2, + self.x - popup_width * 0.2, level_y + bar_height/2, + outline="", fill=self.fg_color, + state='hidden' + ) + left_edge_x = self.x - rw/2 + right_edge_x = self.x + rw/2 + top_edge_y = self.y - self.buffer/4 + self.level_elements['left_lid'] = self.canvas.create_line( + left_edge_x, top_edge_y, + self.x, top_edge_y, + fill=self.fg_color, width=1, + state='normal' + ) + self.level_elements['right_lid'] = self.canvas.create_line( + self.x, top_edge_y, + right_edge_x, top_edge_y, + fill=self.fg_color, width=1, + state='normal' + ) + progress_bar_y = self.y + self.buffer/4 + 2 + progress_bar_height = self.buffer/2 + self.progress_bar_elements['outline'] = self.canvas.create_rectangle( + self.x - rw/2, progress_bar_y, + self.x + rw/2, progress_bar_y + progress_bar_height, + outline=self.fg_color, fill=self.bg_color, width=1 + ) + self.progress_bar_elements['fill'] = self.canvas.create_rectangle( + self.x - rw/2 + 1, progress_bar_y + 1, + self.x - rw/2 + 1, progress_bar_y + progress_bar_height - 1, + outline="", fill=self.fg_color + ) + percentage_text = "0%" + outlined_img = self.create_outlined_text(percentage_text, font_size=14, stroke_width=1) + left_edge = self.x - rw/2 + text_padding = 14 + self.progress_bar_elements['text'] = self.canvas.create_image( + left_edge + text_padding, progress_bar_y + progress_bar_height/2, + image=outlined_img, + anchor=tk.CENTER + ) + self.fix_z_order() + self.update_progress_bar() + + def fix_z_order(self): + self.canvas.tag_raise(self.level_elements['popup_bg']) + self.canvas.tag_raise(self.level_elements['container']) + for key in self.KEYS: + if f'{key}_label' in self.level_elements: + self.canvas.tag_raise(self.level_elements[f'{key}_label']) + if f'{key}_outline' in self.level_elements: + self.canvas.tag_raise(self.level_elements[f'{key}_outline']) + if f'{key}_progress' in self.level_elements: + self.canvas.tag_raise(self.level_elements[f'{key}_progress']) + self.canvas.tag_raise(self.visual_elements['main']) + self.canvas.tag_raise(self.visual_elements['label']) + self.canvas.tag_raise(self.level_elements['left_lid']) + self.canvas.tag_raise(self.level_elements['right_lid']) + self.canvas.tag_raise(self.progress_bar_elements['outline']) + self.canvas.tag_raise(self.progress_bar_elements['fill']) + self.canvas.tag_raise(self.progress_bar_elements['text']) + + def is_full(self): + total_levels = sum(self.levels.values()) + return total_levels >= self.goal + + def add_number(self): + if self.is_full(): + return False + options = [key for key in self.KEYS if self.levels[key] < self.level_goal] + if options: + key = random.choice(options) + self.levels[key] += 1 + self.open() + self.last_refined_time = self.get_millis() + self.update_display() + self.update_progress_bar() + self.fix_z_order() + return True + return False + + def open(self): + if not self.show_levels and not self.opening_animation: + self.animation_start_time = self.get_millis() + self.opening_animation = True + self.closing_animation = False + self.show_levels = True + + def update(self): + current_time = self.get_millis() + if self.opening_animation: + elapsed = current_time - self.animation_start_time + if elapsed >= self.LID_OPEN_CLOSE_TIME: + self.opening_animation = False + self.animation_progress = 1.0 + else: + progress = elapsed / self.LID_OPEN_CLOSE_TIME + self.animation_progress = 1 - (1 - progress) * (1 - progress) + self.update_display() + self.fix_z_order() + elif self.show_levels and not self.closing_animation: + if current_time - self.last_refined_time > self.MAX_SHOW_TIME: + self.closing_animation = True + self.animation_start_time = current_time + elif self.closing_animation: + elapsed = current_time - self.animation_start_time + if elapsed >= self.LID_OPEN_CLOSE_TIME: + self.closing_animation = False + self.show_levels = False + self.animation_progress = 0.0 + else: + progress = elapsed / self.LID_OPEN_CLOSE_TIME + self.animation_progress = 1.0 - (progress * progress) + self.update_display() + self.fix_z_order() + self.update_progress_bar() + + def update_progress_bar(self): + total_levels = sum(self.levels.values()) + completion_percentage = (total_levels / self.goal) * 100 if self.goal > 0 else 0 + rw = self.w + progress_bar_y = self.y + self.buffer/4 + 2 + progress_bar_height = self.buffer/2 + fill_width = (rw * completion_percentage) / 100 + if completion_percentage == 0: + self.canvas.coords( + self.progress_bar_elements['fill'], + self.x - rw/2 + 1, progress_bar_y + 1, + self.x - rw/2 + 1, progress_bar_y + progress_bar_height - 1 + ) + else: + self.canvas.coords( + self.progress_bar_elements['fill'], + self.x - rw/2 + 1, progress_bar_y + 1, + self.x - rw/2 + max(1, fill_width), progress_bar_y + progress_bar_height - 1 + ) + percentage_text = f"{int(completion_percentage)}%" + outlined_img = self.create_outlined_text( + percentage_text, + font_size=14, + stroke_width=1 + ) + left_edge = self.x - rw/2 + text_padding = 30 if completion_percentage >= 100 else 24 + self.canvas.itemconfig(self.progress_bar_elements['text'], image=outlined_img) + self.canvas.coords( + self.progress_bar_elements['text'], + left_edge + text_padding, progress_bar_y + progress_bar_height/2 + ) + + def update_display(self): + self.count = sum(self.levels.values()) + self.count = min(max(self.count, 0), self.goal) + rw = self.w + popup_width = rw + popup_height = self.buffer * 3 + base_y = self.y - self.buffer/4 + popup_y_closed = base_y + popup_y_open = base_y - popup_height/2 + current_popup_y = self.map_value( + self.animation_progress, + 0, 1, + popup_y_closed, popup_y_open + ) + self.canvas.coords( + self.level_elements['popup_bg'], + self.x - popup_width/2, current_popup_y - popup_height/2, + self.x + popup_width/2, current_popup_y + popup_height/2 + ) + self.canvas.coords( + self.level_elements['container'], + self.x - popup_width/2, current_popup_y - popup_height/2, + self.x + popup_width/2, current_popup_y + popup_height/2 + ) + left_edge_x = self.x - rw/2 + right_edge_x = self.x + rw/2 + top_edge_y = base_y + max_lid_angle = 120 + current_angle = self.animation_progress * max_lid_angle + angle_rad = math.radians(current_angle) + + left_lid_end_x = left_edge_x + (rw/2) * math.cos(angle_rad) + left_lid_end_y = top_edge_y - (rw/2) * math.sin(angle_rad) + right_lid_end_x = right_edge_x - (rw/2) * math.cos(angle_rad) + right_lid_end_y = top_edge_y - (rw/2) * math.sin(angle_rad) + self.canvas.coords( + self.level_elements['left_lid'], + left_edge_x, top_edge_y, + left_lid_end_x, left_lid_end_y + ) + self.canvas.coords( + self.level_elements['right_lid'], + right_edge_x, top_edge_y, + right_lid_end_x, right_lid_end_y + ) + visibility_threshold = 0.05 + state = 'normal' if self.animation_progress > visibility_threshold else 'hidden' + + self.canvas.itemconfig(self.level_elements['popup_bg'], state=state) + self.canvas.itemconfig(self.level_elements['container'], state=state) + self.canvas.itemconfig(self.level_elements['left_lid'], state='normal') + self.canvas.itemconfig(self.level_elements['right_lid'], state='normal') + self.update_level_displays(current_popup_y, popup_height, popup_width) + if state == 'hidden': + for key in self.KEYS: + if f'{key}_label' in self.level_elements: + self.canvas.itemconfig(self.level_elements[f'{key}_label'], state='hidden') + if f'{key}_outline' in self.level_elements: + self.canvas.itemconfig(self.level_elements[f'{key}_outline'], state='hidden') + if f'{key}_progress' in self.level_elements: + self.canvas.itemconfig(self.level_elements[f'{key}_progress'], state='hidden') + + def update_level_displays(self, popup_y, popup_height, popup_width): + bar_height = popup_height * 0.15 + state = 'normal' if self.animation_progress > 0.05 else 'hidden' + bar_width = popup_width * 0.6 + for i, key in enumerate(self.KEYS): + level_y = popup_y - popup_height * 0.3 + i * (popup_height * 0.6 / 3) + self.canvas.coords( + self.level_elements[f'{key}_label'], + self.x - popup_width * 0.4, level_y + ) + self.canvas.coords( + self.level_elements[f'{key}_outline'], + self.x - popup_width * 0.2, level_y - bar_height/2, + self.x + popup_width * 0.4, level_y + bar_height/2 + ) + progress_width = ((bar_width * self.levels[key]) + / self.level_goal if self.level_goal > 0 else 0) + self.canvas.coords( + self.level_elements[f'{key}_progress'], + self.x - popup_width * 0.2, level_y - bar_height/2, + self.x - popup_width * 0.2 + progress_width, level_y + bar_height/2 + ) + self.canvas.itemconfig(self.level_elements[f'{key}_label'], state=state) + self.canvas.itemconfig(self.level_elements[f'{key}_outline'], state=state) + self.canvas.itemconfig(self.level_elements[f'{key}_progress'], state=state) + + def resize(self, new_w): + self.w = new_w + self.x = self.i * new_w + new_w * 0.5 + self.y = self.canvas.winfo_height() - 50 + for key in list(self.visual_elements.keys()): + try: + self.canvas.delete(self.visual_elements[key]) + except Exception: + pass + + for key in list(self.level_elements.keys()): + try: + self.canvas.delete(self.level_elements[key]) + except Exception: + pass + + for key in list(self.progress_bar_elements.keys()): + try: + self.canvas.delete(self.progress_bar_elements[key]) + except Exception: + pass + self.visual_elements = {} + self.level_elements = {} + self.progress_bar_elements = {} + self.create_visual_elements() + self.update_display() + + @staticmethod + def get_millis(): + return int(time.time() * 1000) + + @staticmethod + def map_value(value, start1, stop1, start2, stop2): + if stop1 == start1: + return start2 + return start2 + (stop2 - start2) * ((value - start1) / (stop1 - start1)) diff --git a/Raspberry_Pi_Severance_MDR_Terminal/lumon-logo-small.png b/Raspberry_Pi_Severance_MDR_Terminal/lumon-logo-small.png new file mode 100644 index 000000000..17839cd21 Binary files /dev/null and b/Raspberry_Pi_Severance_MDR_Terminal/lumon-logo-small.png differ diff --git a/Raspberry_Pi_Severance_MDR_Terminal/lumon-logo.png b/Raspberry_Pi_Severance_MDR_Terminal/lumon-logo.png new file mode 100644 index 000000000..4b64e6622 Binary files /dev/null and b/Raspberry_Pi_Severance_MDR_Terminal/lumon-logo.png differ diff --git a/Raspberry_Pi_Severance_MDR_Terminal/lumon.py b/Raspberry_Pi_Severance_MDR_Terminal/lumon.py new file mode 100644 index 000000000..74750daae --- /dev/null +++ b/Raspberry_Pi_Severance_MDR_Terminal/lumon.py @@ -0,0 +1,1068 @@ +# SPDX-FileCopyrightText: 2025 Liz Clark for Adafruit Industries +# SPDX-License-Identifier: MIT + +# Based on https://github.com/Lumon-Industries/Macrodata-Refinement +# JavaScript project + +import json +import os +import math +import hashlib +import shutil +import time +import random +import tkinter as tk +from PIL import Image, ImageTk, ImageFont, ImageDraw +from data import DataNumber +from data_bin import Bin +from palette import Palette +try: # blinka with haptics? + import board + import busio + import adafruit_drv2605 +except NotImplementedError: + pass + +TOTAL_REFINEMENT_GOAL = 250 # how many numbers to refine? +LOCATION = "Cold Harbor" + +def get_username(): + username = os.environ.get('USER') + if username: + return username + username = os.environ.get('LOGNAME') + if username: + return username + username = os.environ.get('USERNAME') + if username: + return username + return "Mark S." + +def generate_serial_number(username): + """ + Generate a Severance-style serial number based on the username. + Format: 0xAAAAAA : 0xBBBBBB where A and B are hex values derived from username. + """ + username_bytes = username.encode('utf-8') + first_hex = 0 + for i in range(min(3, len(username_bytes))): + first_hex = (first_hex << 8) | username_bytes[i] + second_hex = 0 + if len(username_bytes) > 3: + remaining_bytes = username_bytes[3:] + for i, byte in enumerate(remaining_bytes): + second_hex ^= (byte << (8 * (i % 3))) + else: + hash_obj = hashlib.md5(username_bytes) + digest = hash_obj.digest() + second_hex = int.from_bytes(digest[:3], byteorder='big') + serial = f"0x{first_hex:06X} : 0x{second_hex:06X}" + return serial +def calculate_distance(x1, y1, x2, y2): + """Calculate Euclidean distance between two points""" + return math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) +# pylint: disable=too-many-branches,too-many-lines,broad-except,unused-argument +# pylint: disable=too-many-statements,too-many-locals,too-many-public-methods,too-many-nested-blocks +class MacrodataRefinementTerminal: + def __init__(self, username=None, location=LOCATION): + self.palette = Palette() + if username is None: + username = get_username() + # game settings + self.username = username + self.location = location + self.completion = 0 + self.total_goal = TOTAL_REFINEMENT_GOAL + self.total_refined = 0 + self.image_path = "lumon-logo.png" + self.logo_img = Image.open("lumon-logo-small.png") + + # graphics + self.root = tk.Tk() + self.root.title("Lumon MDR Terminal") + self.screen_width = self.root.winfo_screenwidth() + self.screen_height = self.root.winfo_screenheight() + self.root.geometry(f"{self.screen_width}x{self.screen_height}+0+0") + self.root.grid_rowconfigure(0, weight=1) + self.root.grid_columnconfigure(0, weight=1) + self.root.bind("", self.toggle_fullscreen) + self.root.bind("", self.exit_fullscreen) + self.root.bind("", self.exit_program) + self.root.protocol("WM_DELETE_WINDOW", self.exit_program) + self.root.focus_force() + self.canvas = tk.Canvas( + self.root, + width=self.screen_width, + height=self.screen_height, + bg=self.palette.BG, + highlightthickness=0 + ) + self.canvas.pack(fill="both", expand=True) + try: + self.image = Image.open(self.image_path) + self.photo = ImageTk.PhotoImage(self.image) + self.canvas_image = self.canvas.create_image(0, 0, image=self.photo, anchor="nw") + except Exception as e: + print(f"Error loading image: {e}") + self.canvas_image = None + self.screen = 1 + # macrodata settings + self.completion_message_elements = {} + self.x_pos, self.y_pos = 100, 100 + self.x_speed, self.y_speed = 2, 2 + self.base_size = 24 + self.number_spacing = 50 + self.margin = 80 + self.data_numbers = [] + self.ui_elements = {} + self.selection_start = None + self.selection_rect = None + self.selection_active = False + self.wiggle_numbers = [] + self.wiggle_timer = 0 + self.wiggle_interval = 600 + self.wiggle_amplitude = 2.0 + self.wiggle_speed = 0.2 + self.wiggle_phase = 0 + self.wiggle_phase_x = 0.0 + self.wiggle_phase_y = 0.0 + self.wiggle_phase_rotation = 0.0 + self.numbers_need_selection = False + self.waiting_for_next_wiggle = False + self.next_wiggle_timer = 0 + self.next_wiggle_delay = 210 + self.base_wiggle_amplitude = 1.5 + self.max_wiggle_amplitude = 10.0 + self.proximity_threshold = 350 + self.wiggle_speed_x = 0.058 + self.wiggle_speed_y = 0.047 + self.wiggle_speed_rotation = 0.02 + self.glow_step = 0 + self.fade_step = 0 + self.fade_timer = 0 + self.max_fade_steps = 20 + self.completion_photo = 0 + self.completion_triggered = False + + # mouse settings + self.mouse_x = 0 + self.mouse_y = 0 + self.canvas.bind("", self.track_mouse) + self.bins = [] + self.canvas.bind("", self.start_selection) + self.canvas.bind("", self.update_selection) + self.canvas.bind("", self.end_selection) + self.root.bind("", self.toggle_screen) + # start program & autosave + self.animate() + self.setup_autosave(interval=300) + try: + self.i2c = busio.I2C(board.SCL, board.SDA) + self.drv = adafruit_drv2605.DRV2605(self.i2c) + self.haptic_enabled = True + self.effect_level = 0 + self.haptic_effects = { + 0: 0, + 1: 9, + 2: 13, + 3: 47, + } + print("Haptic feedback initialized") + except Exception as e: + print(f"Haptic initialization error: {e}") + self.haptic_enabled = False + + def update_haptic_intensity(self, proximity_factor): + if not self.haptic_enabled: + return + + if proximity_factor < 0.1: + self.effect_level = 0 + elif proximity_factor < 0.4: + self.effect_level = 1 + elif proximity_factor < 0.7: + self.effect_level = 2 + else: + self.effect_level = 3 + + if self.effect_level == 0: + self.drv.stop() + else: + effect_id = self.haptic_effects[self.effect_level] + self.drv.sequence[0] = adafruit_drv2605.Effect(effect_id) + self.drv.play() + + def track_mouse(self, event): + self.mouse_x = event.x + self.mouse_y = event.y + + def apply_mouse_avoidance(self, number): + """ + Apply an avoidance effect where numbers move away from the mouse cursor + """ + if not hasattr(number, 'mouse_offset_x'): + number.mouse_offset_x = 0 + if not hasattr(number, 'mouse_offset_y'): + number.mouse_offset_y = 0 + if self.screen != 2 or number.bin_it: + return + dx = number.x - self.mouse_x + dy = number.y - self.mouse_y + distance = math.sqrt(dx*dx + dy*dy) + avoidance_radius = 100 + max_repel_distance = 12 + if distance < avoidance_radius: + normalized_distance = 1.0 - (distance / avoidance_radius) + repel_factor = normalized_distance * normalized_distance * 0.8 + repel_distance = max_repel_distance * repel_factor + if distance > 0.1: + repel_x = (dx / distance) * repel_distance + repel_y = (dy / distance) * repel_distance + else: + angle = random.uniform(0, 2 * math.pi) + repel_x = math.cos(angle) * repel_distance + repel_y = math.sin(angle) * repel_distance + number.mouse_offset_x = repel_x + number.mouse_offset_y = repel_y + else: + if hasattr(number, 'mouse_offset_x') and number.mouse_offset_x != 0: + number.mouse_offset_x *= 0.95 + if abs(number.mouse_offset_x) < 0.05: + number.mouse_offset_x = 0 + if hasattr(number, 'mouse_offset_y') and number.mouse_offset_y != 0: + number.mouse_offset_y *= 0.95 + if abs(number.mouse_offset_y) < 0.05: + number.mouse_offset_y = 0 + + def save_progress(self, filepath=None): + """ + Save the current progress and bin data to a JSON file. + """ + if self.screen != 2 or not self.bins: + return False + if filepath is None: + save_dir = f"/home/{self.username}/mdr_saves/" + os.makedirs(save_dir, exist_ok=True) + filepath = os.path.join(save_dir, f"mdr_{self.location.lower()}.json") + try: + save_data = { + "timestamp": int(time.time()), + "username": self.username, + "location": self.location, + "completion": self.completion, + "total_goal": self.total_goal, + "total_refined": self.total_refined, + "bins": [] + } + for bin_idx, bin_obj in enumerate(self.bins): + bin_data = { + "bin_id": bin_idx, + "levels": bin_obj.levels, + "last_refined_time": bin_obj.last_refined_time + } + save_data["bins"].append(bin_data) + with open(filepath, 'w') as f: + json.dump(save_data, f, indent=2) + print(f"Progress autosaved to {filepath}") + return True + except Exception as e: + print(f"Error saving progress: {str(e)}") + return False + + def load_progress(self, filepath=None): + """ + Load progress and bin data from a JSON file. + Verifies that the saved total goal matches the current total goal, + otherwise starts a new save. + """ + if filepath is None: + filepath = os.path.join(f"/home/{self.username}/mdr_saves/", + f"mdr_{self.location.lower()}.json") + if not os.path.exists(filepath): + print(f"Save file {filepath} not found") + return False + try: + with open(filepath, 'r') as f: + save_data = json.load(f) + required_keys = ["timestamp", "username", "location", "completion", "bins"] + for key in required_keys: + if key not in save_data: + print(f"Invalid save file: missing '{key}' field") + return False + if "total_goal" in save_data: + saved_goal = save_data["total_goal"] + if saved_goal != TOTAL_REFINEMENT_GOAL: + print("Starting fresh with new refinement goal") + backup_path = filepath + f".goal_{saved_goal}.bak" + try: + shutil.copy2(filepath, backup_path) + print(f"Created backup of old save at {backup_path}") + except Exception as backup_err: + print(f"Warning: Could not create backup: {str(backup_err)}") + return False + self.completion = save_data["completion"] + if "total_goal" in save_data: + self.total_goal = save_data["total_goal"] + else: + self.total_goal = TOTAL_REFINEMENT_GOAL + + if "total_refined" in save_data: + self.total_refined = save_data["total_refined"] + if self.screen == 2 and self.bins: + for bin_data in save_data["bins"]: + bin_idx = bin_data["bin_id"] + if bin_idx < len(self.bins): + self.bins[bin_idx].levels = bin_data["levels"] + self.bins[bin_idx].last_refined_time = bin_data["last_refined_time"] + self.bins[bin_idx].update_progress_bar() + if "total_refined" not in save_data: + self.update_total_refined() + self.update_top_progress_bar() + return True + except Exception as e: + print(f"Error loading progress: {str(e)}") + return False + + def setup_autosave(self, interval=300): + """ + Setup automatic saving at five minutes. + """ + self.save_progress() + self.root.after(interval * 1000, lambda: self.setup_autosave(interval)) + + def create_ui_elements(self): + """Create the UI elements for the MDR terminal""" + for element_id in self.ui_elements.values(): + self.canvas.delete(element_id) + self.ui_elements.clear() + if hasattr(self, 'progress_fill_id'): + self.canvas.delete(self.progress_fill_id) + usable_width = self.screen_width - (2 * self.margin) + header_height = 40 + footer_height = 30 + self.ui_elements['top_frame'] = self.canvas.create_rectangle( + self.margin - 5, self.margin - 5, + self.margin + usable_width -50, self.margin + header_height + 5, + outline=self.palette.FG, fill=self.palette.BG, width=2 + ) + print(self.canvas.bbox(self.ui_elements['top_frame'])) + self.ui_elements['top_curve'] = self.canvas.create_line( + self.margin, self.margin + header_height + 15, + self.margin + usable_width, self.margin + header_height + 15, + fill=self.palette.FG, width=2 + ) + self.ui_elements['location'] = self.canvas.create_text( + self.margin + 20, self.margin + header_height/2, + text=self.location, + font=('Arial', 18), + fill=self.palette.FG, + anchor='w' + ) + logo_x = self.margin + usable_width - 75 + logo_y = self.margin + header_height/2 + self.lumon_logo_photo = ImageTk.PhotoImage(self.logo_img) # pylint: disable=attribute-defined-outside-init + self.ui_elements['logo'] = self.canvas.create_image( + logo_x, logo_y, + image=self.lumon_logo_photo, + anchor=tk.CENTER + ) + logo_bbox = self.canvas.bbox(self.ui_elements['logo']) + completion_text = f"{self.completion}% Complete" + outlined_img = self.create_outlined_text(completion_text, font_size=20, stroke_width=1) + self.completion_photo = outlined_img + completion_x = logo_bbox[0] - 20 + self.ui_elements['completion'] = self.canvas.create_image( + completion_x, self.margin + header_height/2, + image=outlined_img, + anchor=tk.E + ) + + bins_y_position = self.screen_height - self.margin - 100 + progress_bar_height = 30 + spacing_after_bar = 30 + bottom_frame_y = bins_y_position + 5 + progress_bar_height + spacing_after_bar + self.ui_elements['bottom_curve'] = self.canvas.create_line( + self.margin, bins_y_position - 25, + self.margin + usable_width, bins_y_position - 25, + fill=self.palette.FG, width=2 + ) + self.ui_elements['bottom_shield'] = self.canvas.create_rectangle( + self.margin - 5, bottom_frame_y-16, + self.margin + usable_width + 5, bottom_frame_y, + outline='', fill=self.palette.BG, width=2 + ) + self.ui_elements['bottom_frame'] = self.canvas.create_rectangle( + self.margin - 5, bottom_frame_y, + self.margin + usable_width + 5, bottom_frame_y + footer_height, + outline=self.palette.FG, fill=self.palette.FG, width=2 + ) + serial = generate_serial_number(self.username) + self.ui_elements['serial'] = self.canvas.create_text( + self.margin + usable_width/2, bottom_frame_y + footer_height/2, + text=serial, + font=('Courier', 14), + fill=self.palette.BG + ) + if 'completion' in self.ui_elements: + self.canvas.tag_raise(self.ui_elements['completion']) + print("Raised completion text to top") + self.update_top_progress_bar() + + def create_outlined_text(self, text, font_size=24, stroke_width=1): + """ + Creates an image with outlined text using PIL's stroke feature + """ + font = ImageFont.truetype("/usr/share/fonts/truetype/msttcorefonts/arial.ttf", font_size) + dummy_img = Image.new("RGBA", (1, 1), (0, 0, 0, 0)) + dummy_draw = ImageDraw.Draw(dummy_img) + bbox = dummy_draw.textbbox((0, 0), text, font=font) + text_width = bbox[2] - bbox[0] + text_height = bbox[3] - bbox[1] + padding = 6 + width = text_width + padding * 2 + stroke_width * 2 + height = text_height + padding * 2 + stroke_width * 2 + + img = Image.new("RGBA", (width, height), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + fill_color = self.palette.BG + stroke_color = self.palette.FG + position = (padding + stroke_width, padding) + draw.text(position, text, font=font, fill=fill_color, + stroke_width=stroke_width, stroke_fill=stroke_color) + photo = ImageTk.PhotoImage(img) + return photo + + def create_completion_text(self, text): + """Creates and returns a canvas image item with stroke-outlined text""" + photo = self.create_outlined_text(text) + self.completion_photo = photo + return photo + + def create_bins(self): + """Create bins at the bottom of the screen""" + bin_count = 5 + usable_width = self.screen_width - (2 * self.margin) + actual_bin_width = (usable_width / bin_count) * 0.9 + spacing = (usable_width - (bin_count * actual_bin_width)) / (bin_count + 1) + + for bin_obj in self.bins: + if hasattr(bin_obj, 'visual_elements'): + for element_id in bin_obj.visual_elements.values(): + self.canvas.delete(element_id) + if hasattr(bin_obj, 'level_elements'): + for element_id in bin_obj.level_elements.values(): + self.canvas.delete(element_id) + if hasattr(bin_obj, 'progress_bar_elements'): + for element_id in bin_obj.progress_bar_elements.values(): + self.canvas.delete(element_id) + self.bins.clear() + bin_goal = self.total_goal // bin_count + bins_y_position = self.screen_height - self.margin - 100 + for i in range(bin_count): + bin_obj = Bin(actual_bin_width, i, bin_goal, self.canvas, palette=self.palette) + x_pos = (self.margin + spacing + (i *(actual_bin_width + spacing)) + + (actual_bin_width / 2)) + bin_obj.x = x_pos + bin_obj.y = bins_y_position + self.bins.append(bin_obj) + bin_obj.create_visual_elements() + bin_obj.update_progress_bar() + + if 'bottom_shield' in self.ui_elements: + self.canvas.tag_raise(self.ui_elements['bottom_shield']) + if 'bottom_frame' in self.ui_elements: + self.canvas.tag_raise(self.ui_elements['bottom_frame']) + if 'serial' in self.ui_elements: + self.canvas.tag_raise(self.ui_elements['serial']) + + def update_total_refined(self): + """Update total refined count based on all bins' levels""" + previous_total = self.total_refined + self.total_refined = 0 + + for bin_obj in self.bins: + bin_total = sum(bin_obj.levels.values()) + self.total_refined += bin_total + if self.total_refined != previous_total: + self.update_overall_completion() + + def update_completion_text(self): + """Updates the completion text with new percentage""" + if 'completion' in self.ui_elements: + completion_text = f"{self.completion}% Complete" + outlined_img = self.create_completion_text(completion_text) + self.canvas.itemconfig(self.ui_elements['completion'], image=outlined_img) + + def should_update_completion(self): + """Check if we should update the completion percentage""" + if not self.bins: + return False + for bin_obj in self.bins: + if bin_obj.show_levels or bin_obj.opening_animation or bin_obj.closing_animation: + return False + return True + + def calculate_bin_percentages(self): + """Calculate the average completion percentage across all bins""" + if not self.bins: + return 0 + bin_percentages = [] + for bin_obj in self.bins: + total_levels = sum(bin_obj.levels.values()) + max_possible = bin_obj.level_goal * len(bin_obj.KEYS) + bin_percentage = (total_levels / max_possible) * 100 if max_possible > 0 else 0 + bin_percentages.append(bin_percentage) + avg_percentage = sum(bin_percentages) / len(bin_percentages) if bin_percentages else 0 + return avg_percentage + + def update_overall_completion(self): + """ + Update the overall completion percentage based on total numbers refined. + """ + if not self.bins: + return + raw_completion = (self.total_refined / self.total_goal) * 100 + self.completion = int(raw_completion) + self.update_top_progress_bar() + + def update_top_progress_bar(self): + """ + Update the top progress bar based on bin percentages. + """ + total_percentage = 0 + bin_count = 0 + for bin_obj in self.bins: + total_levels = sum(bin_obj.levels.values()) + max_possible = bin_obj.level_goal * len(bin_obj.KEYS) + bin_percentage = (total_levels / max_possible) * 100 if max_possible > 0 else 0 + total_percentage += bin_percentage + bin_count += 1 + if bin_count > 0: + calculated_completion = int(total_percentage / bin_count) + self.completion = calculated_completion + frame_bbox = self.canvas.bbox(self.ui_elements['top_frame']) + frame_left = frame_bbox[0] + frame_top = frame_bbox[1] + frame_bottom = frame_bbox[3] + logo_bbox = self.canvas.bbox(self.ui_elements['logo']) + logo_left = logo_bbox[0] + location_right = frame_left + if 'location' in self.ui_elements: + location_bbox = self.canvas.bbox(self.ui_elements['location']) + location_right = location_bbox[2] + 20 + fill_right = logo_left + 15 + fillable_width = fill_right - location_right - 4 + fill_width = (self.completion / 100) * fillable_width + if self.completion == 0: + fill_left = fill_right + else: + fill_left = fill_right - fill_width + fill_left = max(fill_left, location_right) + if 'completion' in self.ui_elements: + completion_text = f"{self.completion}% Complete" + outlined_img = self.create_outlined_text(completion_text, font_size=20, stroke_width=1) + self.completion_photo = outlined_img + self.canvas.itemconfig(self.ui_elements['completion'], image=self.completion_photo) + completion_x = logo_left - 20 + if 'completion' in self.ui_elements: + self.canvas.coords( + self.ui_elements['completion'], + completion_x, self.margin + 40/2 + ) + self.canvas.tag_raise(self.ui_elements['completion']) + if hasattr(self, 'progress_fill_id'): + self.canvas.delete(self.progress_fill_id) # pylint: disable=access-member-before-definition + self.progress_fill_id = self.canvas.create_rectangle( # pylint: disable=attribute-defined-outside-init + fill_left, frame_top + 2, + fill_right, frame_bottom - 2, + fill=self.palette.FG, + outline="", + width=0 + ) + if 'location' in self.ui_elements: + self.canvas.tag_raise(self.ui_elements['location']) + if 'completion' in self.ui_elements: + self.canvas.tag_raise(self.ui_elements['completion']) + if 'logo' in self.ui_elements: + self.canvas.tag_raise(self.ui_elements['logo']) + + def toggle_fullscreen(self, event=None): + """Toggle fullscreen mode""" + is_fullscreen = self.root.attributes("-fullscreen") + self.root.attributes("-fullscreen", not is_fullscreen) + return "break" + + def exit_fullscreen(self, event=None): + """Exit fullscreen mode and quit application""" + self.root.attributes("-fullscreen", False) + self.exit_program() + return "break" + + def exit_program(self, event=None): + """Exit the program and clean up resources""" + if hasattr(self, 'haptic_enabled') and self.haptic_enabled: + try: + self.drv.stop() + except Exception as e: + print(f"Error stopping haptic motor: {e}") + self.root.quit() + self.root.destroy() + + def move_logo(self): + """Animate the logo bouncing around""" + if self.screen == 1 and self.canvas_image: + self.x_pos += self.x_speed + self.y_pos += self.y_speed + if self.x_pos + self.photo.width() >= self.screen_width or self.x_pos <= 0: + self.x_speed = -self.x_speed + if self.y_pos + self.photo.height() >= self.screen_height or self.y_pos <= 0: + self.y_speed = -self.y_speed + self.canvas.coords(self.canvas_image, self.x_pos, self.y_pos) + + def create_number_grid(self): + """Create the grid of numbers with proper horizontal centering""" + for number in self.data_numbers: + self.canvas.delete(number.text_id) + self.data_numbers.clear() + num_columns = 22 + num_rows = 8 + usable_width = self.screen_width - (2 * self.margin) + header_height = 40 + bottom_line_y = self.screen_height - self.margin - 80 - 25 + total_available_height = bottom_line_y - (self.margin + header_height + 15) + horizontal_spacing = usable_width / (num_columns + 1) + vertical_spacing = total_available_height / (num_rows + 1) + grid_width = horizontal_spacing * num_columns + grid_height = vertical_spacing * num_rows + start_x = self.margin + (usable_width - grid_width) / 2 + horizontal_spacing/2 + start_y = self.margin + header_height + 15 + (total_available_height + - grid_height) / 2 + vertical_spacing/2 + for row in range(num_rows): + for col in range(num_columns): + x = start_x + (col * horizontal_spacing) + y = start_y + (row * vertical_spacing) + data_number = DataNumber(x, y, self.canvas, self.base_size, palette=self.palette) + self.data_numbers.append(data_number) + + def update_numbers(self): + """Update the number animations""" + if self.screen == 2: + for number in self.data_numbers: + if number.bin_it: + number.go_bin() + else: + number.go_home() + + def update_bins(self): + """Update the bin animations and ensure proper z-ordering""" + if self.screen == 2: + self.update_total_refined() + self.check_for_completion() + + for bin_obj in self.bins: + bin_obj.update() + if 'bottom_shield' in self.ui_elements: + self.canvas.tag_raise(self.ui_elements['bottom_shield']) + if 'bottom_frame' in self.ui_elements: + self.canvas.tag_raise(self.ui_elements['bottom_frame']) + if 'serial' in self.ui_elements: + self.canvas.tag_raise(self.ui_elements['serial']) + for number in self.data_numbers: + if number.bin_it and number.bin == bin_obj: + if hasattr(bin_obj, 'level_elements'): + for element_id in bin_obj.level_elements.values(): + self.canvas.tag_raise(number.text_id, element_id) + + def toggle_screen(self, event): + """Toggle between logo and number screens with autosave""" + if self.screen == 1: + self.root.attributes("-fullscreen", True) + self.screen = 2 + if self.canvas_image: + self.canvas.itemconfig(self.canvas_image, state='hidden') + self.canvas.configure(bg=self.palette.BG) + self.completion = 0 + self.total_refined = 0 + if hasattr(self, 'completion_triggered'): + self.completion_triggered = False + if hasattr(self, 'completion_message_elements'): + for element_id in self.completion_message_elements.values(): + self.canvas.delete(element_id) + self.completion_message_elements = {} + self.create_ui_elements() + self.create_bins() + self.create_number_grid() + self.wiggle_timer = 0 + self.waiting_for_next_wiggle = False + self.next_wiggle_timer = 0 + load_successful = self.load_progress() + all_bins_full = all(bin_obj.is_full() for bin_obj in self.bins) + if all_bins_full: + print("All bins were previously full. Resetting progress to start over.") + for bin_obj in self.bins: + bin_obj.levels = { + 'WO': 0, + 'FC': 0, + 'DR': 0, + 'MA': 0 + } + bin_obj.update_progress_bar() + self.total_refined = 0 + self.completion = 0 + self.update_top_progress_bar() + elif not load_successful: + self.total_refined = 0 + self.completion = 0 + for bin_obj in self.bins: + bin_obj.levels = { + 'WO': 0, + 'FC': 0, + 'DR': 0, + 'MA': 0 + } + bin_obj.update_progress_bar() + self.update_top_progress_bar() + self.select_random_wiggle_group() + else: + self.save_progress() + self.root.attributes("-fullscreen", True) + self.wiggle_numbers.clear() + self.wiggle_timer = 0 + self.waiting_for_next_wiggle = False + self.next_wiggle_timer = 0 + if hasattr(self, 'progress_fill_id'): + self.canvas.delete(self.progress_fill_id) + for element_id in self.ui_elements.values(): + self.canvas.delete(element_id) + self.ui_elements.clear() + for bin_obj in self.bins: + if hasattr(bin_obj, 'visual_elements'): + for element_id in bin_obj.visual_elements.values(): + self.canvas.delete(element_id) + if hasattr(bin_obj, 'level_elements'): + for element_id in bin_obj.level_elements.values(): + self.canvas.delete(element_id) + if hasattr(bin_obj, 'progress_bar_elements'): + for element_id in bin_obj.progress_bar_elements.values(): + self.canvas.delete(element_id) + self.bins.clear() + for number in self.data_numbers: + self.canvas.delete(number.text_id) + self.data_numbers.clear() + if hasattr(self, 'top_progress_elements'): + for element_id in self.top_progress_elements.values(): + self.canvas.delete(element_id) + self.top_progress_elements.clear() + if hasattr(self, 'completion_message_elements'): + for element_id in self.completion_message_elements.values(): + self.canvas.delete(element_id) + self.completion_message_elements.clear() + if self.selection_rect: + self.canvas.delete(self.selection_rect) + self.selection_rect = None + self.selection_active = False + self.screen = 1 + self.canvas.configure(bg=self.palette.BG) + if self.canvas_image: + self.canvas.itemconfig(self.canvas_image, state='normal') + self.canvas.tag_raise(self.canvas_image) + if hasattr(self, 'x_pos') and hasattr(self, 'y_pos'): + self.canvas.coords(self.canvas_image, self.x_pos, self.y_pos) + + def start_selection(self, event): + """Start a selection box when mouse is pressed""" + if self.screen == 2: + self.selection_start = (event.x, event.y) + self.selection_rect = self.canvas.create_rectangle( + event.x, event.y, event.x, event.y, + outline=self.palette.SELECT, width=2, dash=(5, 5) + ) + self.selection_active = True + + def update_selection(self, event): + """Update the selection box when mouse is dragged""" + if self.screen == 2 and self.selection_active: + x1, y1 = self.selection_start + x2, y2 = event.x, event.y + self.canvas.coords(self.selection_rect, x1, y1, x2, y2) + + def end_selection(self, event): + """Process the selection when mouse is released""" + if self.screen == 2 and self.selection_active: + x1, y1 = self.selection_start + x2, y2 = event.x, event.y + selected_numbers = [] + for number in self.data_numbers: + if number.inside(x1, y1, x2, y2) and not number.bin_it: + selected_numbers.append(number) + number.turn(self.palette.SELECT) + wiggle_selected = [n for n in selected_numbers if n in self.wiggle_numbers] + non_wiggle_selected = [n for n in selected_numbers if n not in self.wiggle_numbers] + wiggle_capture_percent = (len(wiggle_selected) / len(self.wiggle_numbers) + if self.wiggle_numbers else 0) + valid_selection = ( + wiggle_capture_percent >= 0.7 and + len(non_wiggle_selected) <= len(wiggle_selected) * 2 + ) + if valid_selection: + self.pulse_selected(wiggle_selected, 3) + self.refine_numbers(wiggle_selected) + for number in wiggle_selected: + if number in self.wiggle_numbers: + self.wiggle_numbers.remove(number) + number.needs_refinement = False + self.waiting_for_next_wiggle = True + self.next_wiggle_timer = 0 + else: + for number in selected_numbers: + number.turn(self.palette.FG) + self.update_overall_completion() + self.canvas.delete(self.selection_rect) + self.selection_active = False + + def pulse_selected(self, numbers, count, size_factor=1.5, current=0): + """Create a pulsing animation for selected numbers""" + if current >= count * 2: + return + if current < count: + progress = current / count + size_mod = 1.0 + (progress * (size_factor - 1.0)) + else: + progress = (current - count) / count + size_mod = size_factor - (progress * (size_factor - 1.0)) + for number in numbers: + number.set_size(self.base_size * size_mod) + self.root.after(50, lambda: self.pulse_selected(numbers, count, size_factor, current + 1)) + + def refine_numbers(self, numbers): + """Send selected numbers to bins based on their horizontal position""" + wiggling_numbers = [n for n in numbers if n in self.wiggle_numbers] + if not wiggling_numbers: + return + DataNumber.reset_active_bin() + all_bins_full = all(bin_obj.is_full() for bin_obj in self.bins) + if all_bins_full: + print("All bins are full - can't refine more numbers") + return + number_positions = {} + for number in wiggling_numbers: + target_bin = number.get_non_full_bin_for_position(self.bins) + if target_bin: + if target_bin not in number_positions: + number_positions[target_bin] = [] + number_positions[target_bin].append(number) + selected_bin = None + max_count = 0 + for bin_obj, bin_numbers in number_positions.items(): + if len(bin_numbers) > max_count: + max_count = len(bin_numbers) + selected_bin = bin_obj + if selected_bin: + DataNumber.active_bin = selected_bin + for number in wiggling_numbers: + success = number.refine(bin_obj=selected_bin) + if not success: + number.needs_refinement = False + if number in self.wiggle_numbers: + self.wiggle_numbers.remove(number) + + def select_random_wiggle_group(self): + """Randomly select a CLUSTERED group of numbers that need refinement""" + for number in self.wiggle_numbers: + number.needs_refinement = False + number.wiggle_offset_x = 0 + number.wiggle_offset_y = 0 + self.wiggle_numbers.clear() + all_bins_full = all(bin_obj.is_full() for bin_obj in self.bins) + if all_bins_full: + return + if not self.data_numbers: + return + available_numbers = [n for n in self.data_numbers if not n.bin_it] + if not available_numbers: + return + seed_number = random.choice(available_numbers) + for number in available_numbers: + number.distance_to_seed = calculate_distance( + seed_number.x, seed_number.y, number.x, number.y + ) + available_numbers.sort(key=lambda n: n.distance_to_seed) + cluster_size = random.randint(3, 6) + clustered_numbers = available_numbers[:min(cluster_size, len(available_numbers))] + self.wiggle_numbers = clustered_numbers + for number in self.wiggle_numbers: + number.needs_refinement = True + + def wiggle_selected_numbers(self): + """Apply smooth, floating wiggle effect to numbers + that need refinement and update haptics + """ + if self.screen == 2 and self.wiggle_numbers: + self.wiggle_phase_x += self.wiggle_speed_x + self.wiggle_phase_y += self.wiggle_speed_y + self.wiggle_phase_rotation += self.wiggle_speed_rotation + proximity_factor = 0 + if len(self.wiggle_numbers) > 0: + center_x = sum(number.x for number in + self.wiggle_numbers) / len(self.wiggle_numbers) + center_y = sum(number.y for number in + self.wiggle_numbers) / len(self.wiggle_numbers) + distance_to_mouse = math.sqrt((self.mouse_x - center_x)**2 + + (self.mouse_y - center_y)**2) + normalized_distance = max(0, min(1, distance_to_mouse / self.proximity_threshold)) + proximity_factor = 1.0 - normalized_distance + dynamic_amplitude = self.base_wiggle_amplitude + ( + (self.max_wiggle_amplitude - self.base_wiggle_amplitude) * + proximity_factor**1.5 + ) + self.update_haptic_intensity(proximity_factor) + else: + dynamic_amplitude = self.base_wiggle_amplitude + self.update_haptic_intensity(0) + for number in self.wiggle_numbers: + if not number.bin_it: + index = self.wiggle_numbers.index(number) + phase_offset = index * 0.5 + x_freq2 = 0.37 + y_freq2 = 0.29 + primary_x = math.sin(self.wiggle_phase_x + + phase_offset) * dynamic_amplitude + primary_y = math.cos(self.wiggle_phase_y + + phase_offset) * dynamic_amplitude * 0.8 + secondary_x = math.sin(self.wiggle_phase_x * x_freq2 + + phase_offset * 1.3) * dynamic_amplitude * 0.3 + secondary_y = math.cos(self.wiggle_phase_y * y_freq2 + + phase_offset * 0.9) * dynamic_amplitude * 0.25 + rot_x = math.cos(self.wiggle_phase_rotation + + index * 0.7) * dynamic_amplitude * 0.2 + rot_y = math.sin(self.wiggle_phase_rotation + + index * 0.7) * dynamic_amplitude * 0.2 + offset_x = primary_x + secondary_x + rot_x + offset_y = primary_y + secondary_y + rot_y + number.wiggle_offset_x = offset_x + number.wiggle_offset_y = offset_y + number.show_wiggle(proximity_factor) + + def check_for_completion(self): + """Check if all bins are full and trigger completion sequence if needed""" + if self.screen != 2: + return + if hasattr(self, 'completion_triggered') and self.completion_triggered: + return + all_bins_full = all(bin_obj.is_full() for bin_obj in self.bins) + if self.bins: + total_refined = sum(sum(bin_obj.levels.values()) for bin_obj in self.bins) + completion_pct = (total_refined / self.total_goal) * 100 + else: + completion_pct = 0 + if all_bins_full or completion_pct >= 100: + self.completion_sequence() + + def completion_sequence(self): + """Start sequence for completion""" + self.completion_triggered = True + self.fade_out_numbers() + self.completion = 100 + self.update_top_progress_bar() + + def fade_out_numbers(self): + """Fade out all numbers gradually""" + self.fade_step = 0 + self.fade_timer = 0 + self.max_fade_steps = 20 + self.animate_number_fade() + + def animate_number_fade(self): + """Animate the fading out of all numbers""" + if self.fade_step >= self.max_fade_steps: + self.show_completion_message() + return + alpha = int(255 * (1 - (self.fade_step / self.max_fade_steps))) + for number in self.data_numbers: + number.alpha = alpha + number.update_display() + self.fade_step += 1 + self.root.after(50, self.animate_number_fade) + + def show_completion_message(self): + """Show the completion celebration message""" + if hasattr(self, 'completion_message_elements'): + for element_id in self.completion_message_elements.values(): + self.canvas.delete(element_id) + self.completion_message_elements = {} + if 'top_curve' in self.ui_elements: + top_y = self.canvas.coords(self.ui_elements['top_curve'])[1] + 15 + else: + top_y = self.margin + 80 + if 'bottom_curve' in self.ui_elements: + bottom_y = self.canvas.coords(self.ui_elements['bottom_curve'])[1] - 15 + else: + bottom_y = self.screen_height - self.margin - 130 + center_x = self.screen_width / 2 + center_y = (top_y + bottom_y) / 2 + self.completion_message_elements['percent'] = self.canvas.create_text( + center_x, center_y - 30, + text="100%", + font=('Courier', 48, 'bold'), + fill=self.palette.FG, + anchor='center' + ) + self.completion_message_elements['praise'] = self.canvas.create_text( + center_x, center_y + 30, + text="Praise Kier", + font=('Courier', 36, 'bold'), + fill=self.palette.FG, + anchor='center' + ) + self.glow_step = 0 + self.animate_completion_glow() + + def animate_completion_glow(self): + """Create a subtle glowing/pulsing effect for the completion message""" + if (not hasattr(self, 'completion_message_elements') + or 'percent' not in self.completion_message_elements): + return + glow_factor = 0.8 + (0.2 * (math.sin(self.glow_step / 10) + 1) / 2) + percent_size = int(48 * glow_factor) + praise_size = int(36 * glow_factor) + self.canvas.itemconfig( + self.completion_message_elements['percent'], + font=('Courier', percent_size, 'bold') + ) + self.canvas.itemconfig( + self.completion_message_elements['praise'], + font=('Courier', praise_size, 'bold') + ) + self.glow_step += 1 + self.root.after(100, self.animate_completion_glow) + + def animate(self): + """Main animation loop""" + if self.screen == 1: + self.root.attributes("-fullscreen", True) + self.move_logo() + else: + self.update_bins() + self.update_numbers() + for number in self.data_numbers: + self.apply_mouse_avoidance(number) + if self.waiting_for_next_wiggle: + self.next_wiggle_timer += 1 + self.next_wiggle_delay = random.randint(180, 240) + if self.next_wiggle_timer >= self.next_wiggle_delay: + self.waiting_for_next_wiggle = False + self.next_wiggle_timer = 0 + self.select_random_wiggle_group() + elif (not self.wiggle_numbers and not self.waiting_for_next_wiggle + and self.wiggle_timer == 0): + self.select_random_wiggle_group() + self.wiggle_selected_numbers() + self.root.after(20, self.animate) + + def run(self): + """Start the application""" + self.root.mainloop() + +if __name__ == "__main__": + app = MacrodataRefinementTerminal() + app.run() diff --git a/Raspberry_Pi_Severance_MDR_Terminal/mdr.service b/Raspberry_Pi_Severance_MDR_Terminal/mdr.service new file mode 100644 index 000000000..1f8322ddf --- /dev/null +++ b/Raspberry_Pi_Severance_MDR_Terminal/mdr.service @@ -0,0 +1,18 @@ +[Unit] +Description=Macrodata Refinement +After=multi-user.service + +[Service] +Type=simple +PAMName=login +User=pi-lumon +Group=pi-lumon +WorkingDirectory=/home/pi-lumon +Environment=DISPLAY=:0 +Environment=XAUTHORITY=/home/pi-lumon/.Xauthority +ExecStart=/home/pi-lumon/lumon/bin/python /home/pi-lumon/lumon.py +Restart=on-failure +RestartSec=5s + +[Install] +WantedBy=multi-user.target diff --git a/Raspberry_Pi_Severance_MDR_Terminal/palette.py b/Raspberry_Pi_Severance_MDR_Terminal/palette.py new file mode 100644 index 000000000..5775c7faa --- /dev/null +++ b/Raspberry_Pi_Severance_MDR_Terminal/palette.py @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: 2025 Liz Clark for Adafruit Industries +# SPDX-License-Identifier: MIT + +class Palette: + """Color palette based on the Severance MDR terminal""" + def __init__(self): + self.BG = '#010A13' # Dark blue-black background + self.FG = '#ABFFE9' # Light cyan text + self.SELECT = '#ABFFE9' # Selection color