diff --git a/.gitignore b/.gitignore
index 88e2958..9181fc2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -169,4 +169,5 @@ cython_debug/
#.idea/
.DS_Store
-Thumbs.db
\ No newline at end of file
+Thumbs.db
+frames
\ No newline at end of file
diff --git a/README.md b/README.md
index 4e2765a..3129be9 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,8 @@ stateDiagram-v2
[*] --> Main
state "Main View
(timer + weight)" as Main
+ state "Menu
(carousel)" as Menu
+ state "Bluetooth Pairing
(waiting for connection)" as BTPairing
state "Recipe Selection
(carousel)" as RecipeSelection
state "Do Recipe
(step-by-step)" as DoRecipe
state "Edit Recipe
(step list)" as EditRecipe
@@ -26,8 +28,14 @@ stateDiagram-v2
state "Edit Step – Value Entry
(morse input)" as ValueEntry
state "Edit Step – Goal Time Entry
(morse input)" as GoalTime
+ Main --> Menu : both long press
Main --> RecipeSelection : right long press
+ Menu --> Main : left long press [back]
+ Menu --> BTPairing : right long press [BT Pair]
+
+ BTPairing --> Menu : left long press [cancel]
+
RecipeSelection --> Main : left long press
RecipeSelection --> DoRecipe : right long press [on recipe → run]
RecipeSelection --> EditRecipe : both long press [on recipe → edit]
or right long press [on + → add]
@@ -48,4 +56,28 @@ stateDiagram-v2
ValueEntry --> GoalTime : left long press [WEIGH_WITH_TIMER]
GoalTime --> EditRecipe : left long press [save]
or left press [cancel]
-```
\ No newline at end of file
+```
+
+## Tools
+
+### `export_loading_frames.py`
+
+Renders the loading screen animation and exports every unique frame as both PNG files and a NumPy array.
+
+**Usage**
+```bash
+uv run export_loading_frames.py [--out-dir ]
+```
+
+| Argument | Default | Description |
+|---|---|---|
+| `--out-dir` | `frames/` | Directory to write output files into |
+
+**Outputs**
+
+| File | Description |
+|---|---|
+| `frames/frame_XXXX.png` | One PNG per unique frame, zero-padded index |
+| `frames/frames.npy` | NumPy array of shape `(N, H, W)`, dtype `bool` |
+
+Consecutive duplicate frames are collapsed so only visually distinct frames are exported. The script prints the total number of frames rendered and the number of unique frames kept.
diff --git a/export_loading_frames.py b/export_loading_frames.py
new file mode 100644
index 0000000..1e78c67
--- /dev/null
+++ b/export_loading_frames.py
@@ -0,0 +1,73 @@
+"""
+Export loading screen animation as a list of unique, ordered frames.
+
+Outputs:
+ - frames/frame_XXXX.png — one PNG per unique frame
+ - frames/frames.npy — numpy array of shape (n_unique, H, W), dtype bool
+
+Run with:
+ uv run export_loading_frames.py [--out-dir frames]
+"""
+
+import argparse
+import os
+import sys
+
+import numpy as np
+
+from frontend.views.loading import LoadingView
+
+
+def render_all_frames(view: LoadingView) -> list:
+ """Render every animation frame and return as list of PIL Images."""
+ frames = []
+ while True:
+ im, done = view.get_frame()
+ frames.append(im)
+ if done:
+ break
+ return frames
+
+
+def deduplicate(frames: list) -> list:
+ """Keep only frames that differ from the previous one."""
+ unique = [frames[0]]
+ prev = frames[0].tobytes()
+ for im in frames[1:]:
+ b = im.tobytes()
+ if b != prev:
+ unique.append(im)
+ prev = b
+ return unique
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Export loading screen frames.")
+ parser.add_argument("--out-dir", default="frames", help="Output directory")
+ args = parser.parse_args()
+
+ size = (168, 144)
+ center = (168 // 2, 144 // 2)
+
+ view = LoadingView(size, center)
+ all_frames = render_all_frames(view)
+ unique_frames = deduplicate(all_frames)
+
+ print(f"Total frames rendered : {len(all_frames)}")
+ print(f"Unique frames exported: {len(unique_frames)}")
+
+ os.makedirs(args.out_dir, exist_ok=True)
+
+ # Save individual PNGs
+ for i, im in enumerate(unique_frames):
+ im.save(os.path.join(args.out_dir, f"frame_{i:04d}.png"))
+
+ # Save as numpy array (bool, shape: n_frames x H x W)
+ arr = np.stack([np.array(im) for im in unique_frames]) # shape (N, H, W)
+ npy_path = os.path.join(args.out_dir, "frames.npy")
+ np.save(npy_path, arr)
+ print(f"Saved PNGs and {npy_path} ({arr.shape}, {arr.dtype})")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/frontend/config.py b/frontend/config.py
index f51fbba..d075ce3 100644
--- a/frontend/config.py
+++ b/frontend/config.py
@@ -16,6 +16,7 @@ class DISPLAY_TYPES(Flag):
CIRCLE = 2
class DISPLAY_MODES(Enum):
+ LOADING = 0
MAIN = 1
SETTINGS = 2
RECIPE_SELECTION = 3
diff --git a/frontend/views/__init__.py b/frontend/views/__init__.py
index 166c028..1b8e645 100644
--- a/frontend/views/__init__.py
+++ b/frontend/views/__init__.py
@@ -2,4 +2,5 @@ from .number import NumberView
from .circle import CircleView
from .timer import TimerView
from .text import TextView
+from .loading import LoadingView
from .main_view import MainView
\ No newline at end of file
diff --git a/frontend/views/loading.py b/frontend/views/loading.py
new file mode 100644
index 0000000..ef2311c
--- /dev/null
+++ b/frontend/views/loading.py
@@ -0,0 +1,98 @@
+from PIL import Image, ImageDraw
+
+
+class LoadingView:
+ """Loading screen with animated JP logo. Circle matches CircleView."""
+
+ SETUP_FRAMES = 50
+ ANIMATION_FRAMES = 100 # ~1s at 50fps
+ HOLD_FRAMES = 100 # hold completed logo ~9s (total ~10s)
+
+ # SVG cubic bezier segments (absolute coordinates in 500x500 viewport)
+ # P path: starts at center, goes up and curves around the bump
+ _P_SEGMENTS = [
+ ((248.462, 250.406), (248.125, 213.074), (246.529, 178.548), (246.529, 178.548)),
+ ((246.529, 178.548), (250.153, 120.959), (302.781, 109.091), (316.648, 109.906)),
+ ((316.648, 109.906), (384.748, 113.909), (403.0, 185.637), (372.292, 222.732)),
+ ]
+ # J path: starts at bottom-left hook, ends at center
+ _J_SEGMENTS = [
+ ((113.29, 351.138), (138.503, 404.984), (237.45, 410.427), (246.53, 320.295)),
+ ((246.53, 320.295), (248.338, 302.355), (248.69, 275.888), (248.466, 249.666)),
+ ]
+
+ def __init__(self, size, center, radius_offset=11):
+ self.size = size
+ self.center = center
+ self.target_radius = min(center) - radius_offset
+ self.frame = 0
+ self.total_frames = self.SETUP_FRAMES + self.ANIMATION_FRAMES + self.HOLD_FRAMES
+
+ # Transform SVG coords → display coords
+ scale = self.target_radius / 240.0
+ svg_cx, svg_cy = 252.565, 249.335
+ dx = self.center[0] - svg_cx * scale
+ dy = self.center[1] - svg_cy * scale
+
+ self.p_points = self._sample_path(self._P_SEGMENTS, scale, dx, dy)
+ self.j_points = self._sample_path(self._J_SEGMENTS, scale, dx, dy)
+
+ @staticmethod
+ def _sample_path(segments, scale, dx, dy, samples_per_segment=30):
+ points = []
+ for p0, p1, p2, p3 in segments:
+ for i in range(samples_per_segment + 1):
+ if i == 0 and points:
+ continue
+ t = i / samples_per_segment
+ u = 1 - t
+ x = u**3*p0[0] + 3*u**2*t*p1[0] + 3*u*t**2*p2[0] + t**3*p3[0]
+ y = u**3*p0[1] + 3*u**2*t*p1[1] + 3*u*t**2*p2[1] + t**3*p3[1]
+ points.append((x * scale + dx, y * scale + dy))
+ return points
+
+ @staticmethod
+ def _ease_in_out(t):
+ if t < 0.5:
+ return 2 * t * t
+ return 1 - (-2 * t + 2) ** 2 / 2
+
+ def get_frame(self):
+ """Return (PIL Image, is_done) advancing the animation by one frame."""
+ if self.frame >= self.total_frames:
+ return self._render(1.0), True
+
+ if self.frame >= self.SETUP_FRAMES + self.ANIMATION_FRAMES:
+ progress = 1.0
+ elif self.frame < self.SETUP_FRAMES:
+ progress = 0.0
+ else:
+ progress = self._ease_in_out((self.frame - self.SETUP_FRAMES) / self.ANIMATION_FRAMES)
+
+ im = self._render(progress)
+ self.frame += 1
+ return im, False
+
+ def _render(self, progress):
+ im = Image.new('1', self.size, 'white')
+ draw = ImageDraw.Draw(im)
+
+ # Circle outline — same as CircleView
+ draw.circle(self.center, self.target_radius, outline='black')
+
+ # P path: reveal from start forward (center appears first, extends toward bump)
+ p_count = int(progress * len(self.p_points))
+ if p_count > 1:
+ pts = self.p_points[:p_count]
+ for i in range(len(pts) - 1):
+ draw.line([pts[i], pts[i + 1]], fill='black', width=2)
+
+ # J path: reveal from end backward (center appears first, extends toward hook)
+ j_count = int(progress * len(self.j_points))
+ if j_count > 1:
+ start = len(self.j_points) - j_count
+ pts = self.j_points[start:]
+ for i in range(len(pts) - 1):
+ draw.line([pts[i], pts[i + 1]], fill='black', width=2)
+
+ return im
diff --git a/frontend/views/main_view.py b/frontend/views/main_view.py
index 6e62aaf..52e3b05 100644
--- a/frontend/views/main_view.py
+++ b/frontend/views/main_view.py
@@ -6,7 +6,7 @@ from PIL import ImageChops
from ..config import DISPLAY_TYPES, DISPLAY_MODES
from .draw_utils import draw_clock, draw_bluetooth_icon
-from . import NumberView, CircleView, TimerView, TextView
+from . import NumberView, CircleView, TimerView, TextView, LoadingView
from .button_interface import ButtonInterface
from .buttons_manager import ButtonsManager
@@ -24,7 +24,7 @@ class MainView(tk.Frame, ButtonInterface):
bt_stop_pairing_command=None,
**kwargs):
super().__init__(parent, **kwargs)
- self.curr_mode = DISPLAY_MODES.MAIN
+ self.curr_mode = DISPLAY_MODES.LOADING
self.views = []
self.timer_view = None # Timer view is always active
self.tare_command = tare_command
@@ -51,6 +51,7 @@ class MainView(tk.Frame, ButtonInterface):
self.text_view = TextView(self.actions, self.im_size, self.center)
self.buttons = ButtonsManager(self, self.im_size, self.center,
curr_view=self)
+ self.loading_view = LoadingView(self.im_size, self.center)
self.recipes_manager = RecipeManager()
self.current_steps = [None] * 3
self.tare_buffer = []
@@ -280,7 +281,13 @@ class MainView(tk.Frame, ButtonInterface):
def refresh(self, weight: float):
ims = []
- if self.curr_mode == DISPLAY_MODES.MAIN or self.curr_mode == DISPLAY_MODES.DO_RECIPE:
+ if self.curr_mode == DISPLAY_MODES.LOADING:
+ frame, done = self.loading_view.get_frame()
+ ims.append(frame)
+ if done:
+ self.curr_mode = DISPLAY_MODES.MAIN
+
+ elif self.curr_mode == DISPLAY_MODES.MAIN or self.curr_mode == DISPLAY_MODES.DO_RECIPE:
# Always include timer and button view
if self.curr_mode == DISPLAY_MODES.MAIN or \
(self.current_steps[1] is not None and \