Install the Required Libraries
You will need to install the DRV2605 library onto your Raspberry Pi. In the terminal, enter:
pip install adafruit-circuitpython-drv2605
You'll also need some extra fonts:
sudo apt install ttf-mscorefonts-installer
After installing, update the font directory cache with this command:
sudo fc-cache -vr
Download the Project Bundle
Once you've finished setting up your Raspberry Pi with Blinka and the library dependencies, you can access the Python code files and graphics by downloading the Project Bundle.
To do this, click on theย Download Project Bundleย button in the window below. It will download as a zipped folder.
# 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("<F11>", self.toggle_fullscreen)
self.root.bind("<Escape>", self.exit_fullscreen)
self.root.bind("<q>", 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("<Motion>", self.track_mouse)
self.bins = []
self.canvas.bind("<Button-1>", self.start_selection)
self.canvas.bind("<B1-Motion>", self.update_selection)
self.canvas.bind("<ButtonRelease-1>", self.end_selection)
self.root.bind("<Button-3>", 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()
After downloading the Project Bundle, move the folder to your /home/user directory. Then, unzip the folder by right-clicking on the folder in the File Manager and selecting Extract or with your preferred command line tool. Keep the following files in theย /home/user directory:
- lumon.py
- data.py
- data_bins.py
- palette.py
- lumon-logo.png
- lumon-logo-small.png
How the Code Works
The code consists of four files. Two of the files are helper files: data.py file handles the number grid graphics and data_bins.py handles the bin animations and data. palette.py contains the color palette for the entire program. The main program file is in lumon.py. It handles the gameplay, graphics, mouse input and saving/loading the game progress JSON files.
Special Features
The code is involved since it is basically a full game, but here are some fun features in it:
- Your username on your system is converted to the serial number at the bottom of the screen (0x000000 : 0x000000)
- You can change the goal number for numbers to refine at the top if you want to experience 100% faster
- JSON files are used to save and load progress
- As you move the cursor, the numbers will move out of its path
- As the cursor gets closer to the chosen numbers, the chosen numbers will jump around more and the vibration from the haptic motor will increase in intensity
- Since its written in CPython, it doesn't have to be run on a Raspberry Pi. Most of the development was actually done on Windows. You'll just need to edit some of the file paths to make it fit your system.
Code References
It could be that the Macrodata Refinement Terminal is the new PipBoy for maker prop recreations. I referenced a few open source code iterations when working on this Python version.ย LumonMDR by andrewchilicki is written in C and Macrodata-Refinement by Lumon-Industries is written in Javascript.
Page last edited March 04, 2025
Text editor powered by tinymce.