Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 154 additions & 9 deletions server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ def load_settings():
"notify_enabled": False,
"notify_display_seconds": 10,
"notify_sources": {},
"transition_style": "ltr",
"transition_speed": 15,
"installed_apps": [
"time", "date", "weather", "stocks", "sports", "countdown",
"world_clock", "crypto", "iss", "metro", "youtube", "yt_comments",
Expand Down Expand Up @@ -627,6 +629,127 @@ def m(r, c): return r * cols + c
'\U0001f7e6': 'b', '\U0001f7ea': 'p', '\u2b1c': 'w', '\u2b1b': ' ',
}

def send_to_display_sync(text):
"""Send modules staggered so all arrive at their target character simultaneously."""
global current_indices, current_display_string, is_homed
if not text:
return 0
clean_text = text.upper()
for emoji, char in COLOR_MAP.items():
clean_text = clean_text.replace(emoji, char)
clean_text = clean_text.replace('"', 'q')
n = get_module_count()
clean_text = clean_text.ljust(n)[:n]
logging.info(f"DISPLAY (sync): {clean_text}")

dists = []
for i in range(n):
char = clean_text[i]
target_idx = FLAP_CHARS.find(char)
if target_idx == -1:
target_idx = 0
# Treat -1 (pre-home unknown) as position 0 so sync stagger works on first run
current = 0 if current_indices[i] == -1 else current_indices[i]
dist = (target_idx - current) % 64
dists.append((i, char, target_idx, dist))

max_dist = max(d[3] for d in dists) if dists else 0
dists_sorted = sorted(dists, key=lambda x: -x[3])

t0 = time.time()
with serial_lock:
for i, char, target_idx, dist in dists_sorted:
delay_before = (max_dist - dist) * (4.0 / 64.0)
elapsed = time.time() - t0
remaining = delay_before - elapsed
if remaining > 0:
time.sleep(remaining)
if ser and not sim_mode:
ser.write(f"m{i:02d}-{char}\n".encode())
ser.flush()
current_indices[i] = target_idx

current_display_string = clean_text
is_homed = True
mqtt_publish_state()
return max_dist


def send_to_display_slot(text, effect_speed=80):
"""Slot machine: all modules spin to random chars, then lock in L→R."""
global current_indices, current_display_string, is_homed
if not text:
return 0
clean_text = text.upper()
for emoji, char in COLOR_MAP.items():
clean_text = clean_text.replace(emoji, char)
clean_text = clean_text.replace('"', 'q')
n = get_module_count()
clean_text = clean_text.ljust(n)[:n]
logging.info(f"DISPLAY (slot): {clean_text}")

# Phase 1: all modules spin to random intermediate chars simultaneously
# Ensure spin char differs from target so the lock-in is always visible
spin_chars = []
for i in range(n):
target_idx = FLAP_CHARS.find(clean_text[i])
if target_idx == -1: target_idx = 0
candidates = [c for c in FLAP_CHARS[1:len(FLAP_CHARS)-4] if FLAP_CHARS.find(c) != target_idx]
spin_chars.append(random.choice(candidates) if candidates else FLAP_CHARS[1])
with serial_lock:
for i in range(n):
if ser and not sim_mode:
ser.write(f"m{i:02d}-{spin_chars[i]}\n".encode())
ser.flush()
idx = FLAP_CHARS.find(spin_chars[i])
current_indices[i] = idx if idx != -1 else 0

time.sleep(1.5)

# Phase 2: lock in final chars L→R
max_dist = 0
with serial_lock:
for i in range(n):
char = clean_text[i]
if ser and not sim_mode:
ser.write(f"m{i:02d}-{char}\n".encode())
ser.flush()
time.sleep(effect_speed / 1000.0)
target_idx = FLAP_CHARS.find(char)
if target_idx == -1:
target_idx = 0
dist = (target_idx - current_indices[i]) % 64
if dist > max_dist:
max_dist = dist
current_indices[i] = target_idx

current_display_string = clean_text
is_homed = True
mqtt_publish_state()
return max_dist


def _send_with_effect(page_text, page_style, page_speed, is_anim, app_id=None):
"""Dispatch a page send using the active transition style (per-page > per-app > global)."""
global last_transition_style, last_transition_speed
if is_anim:
return send_to_display(page_text, get_animation_order(page_style or 'ltr'), raw=True, step_delay_ms=page_speed)
# Priority: per-page > per-app > global
style = page_style or \
(settings.get(f'plugin_{app_id}_transition_style') if app_id else None) or \
settings.get('transition_style', 'ltr')
app_speed = settings.get(f'plugin_{app_id}_transition_speed') if app_id else None
speed = page_speed if page_speed is not None else \
(int(app_speed) if app_speed else int(settings.get('transition_speed', 15)))
last_transition_style = style
last_transition_speed = speed
if style == 'sync':
return send_to_display_sync(page_text)
if style == 'slot':
return send_to_display_slot(page_text, effect_speed=speed)
return send_to_display(page_text, get_animation_order(style), step_delay_ms=speed)


def send_to_display(text, order=None, raw=False, step_delay_ms=15):
global current_indices, current_display_string, is_homed
if not text:
Expand Down Expand Up @@ -654,6 +777,12 @@ def send_to_display(text, order=None, raw=False, step_delay_ms=15):
if order is None:
order = list(range(n))

# Update sim state immediately so the browser reflects the new text without waiting
# for the serial loop to complete (fixes sim lag on hardware transitions)
current_display_string = clean_text
is_homed = True
mqtt_publish_state()

max_dist = 0
with serial_lock:
for i in order:
Expand All @@ -673,9 +802,6 @@ def send_to_display(text, order=None, raw=False, step_delay_ms=15):
max_dist = dist
current_indices[i] = target_idx

current_display_string = clean_text
is_homed = True
mqtt_publish_state()
return max_dist


Expand Down Expand Up @@ -992,6 +1118,8 @@ def get_plugin_settings_config():
active_app_playlist = None
app_playlist_loop = True
app_playlist_name = None
last_transition_style = 'ltr'
last_transition_speed = 15

# ── Notification Interrupts ────────────────────────────────
_notify_queue = []
Expand Down Expand Up @@ -1029,6 +1157,7 @@ def _show_notify_message(msg):
def _run_app_playlist():
"""Execute one pass through the app playlist entries."""
global active_app_playlist, active_app, last_sent_page
global last_transition_style, last_transition_speed

entries = active_app_playlist
if not entries:
Expand Down Expand Up @@ -1106,12 +1235,18 @@ def _run_app_playlist():
if stop_event.is_set() or time.time() >= deadline:
break
page_text = page.get('text', '') if isinstance(page, dict) else page
page_order = get_animation_order(page.get('style', 'ltr')) if isinstance(page, dict) else active_order
page_style = page.get('style') if isinstance(page, dict) else None
page_speed = int(page.get('speed', 15)) if isinstance(page, dict) else 15
page_delay = float(page.get('delay', eff_delay)) if isinstance(page, dict) else eff_delay

anim_style_ap = settings.get('anim_style','ltr') if is_anim else None
eff_style_ap = (anim_style_ap or page_style or
(settings.get(f'plugin_{reg}_transition_style') if reg else None) or
settings.get('transition_style', 'ltr'))
last_transition_style = eff_style_ap
last_transition_speed = page_speed if page_speed is not None else int(settings.get('transition_speed', 15))
if is_anim or page_text != last_sent_page:
max_dist = send_to_display(page_text, page_order, raw=is_anim, step_delay_ms=page_speed)
max_dist = _send_with_effect(page_text, page_style if not is_anim else anim_style_ap, page_speed, is_anim, app_id=reg)
last_sent_page = page_text

rotation_time = max_dist * (4.0 / 64.0)
Expand Down Expand Up @@ -1144,6 +1279,7 @@ def _get_pages_for_app(app_key):
def playlist_loop():
global current_playlist, loop_delay, last_sent_page, active_app
global active_app_playlist, app_playlist_loop
global last_transition_style, last_transition_speed

while True:
now = time.time()
Expand Down Expand Up @@ -1207,18 +1343,25 @@ def playlist_loop():
if isinstance(page, dict):
page_text = page.get('text', '')
page_delay = float(page.get('delay', eff_delay))
page_order = get_animation_order(page.get('style', 'ltr'))
page_style = page.get('style')
page_speed = int(page.get('speed', 15))
else:
page_text = page
page_delay = eff_delay
page_order = active_order
page_style = None
page_speed = 15

max_dist = 0
# Animations always resend each frame; other apps skip unchanged pages
anim_style = settings.get('anim_style', 'ltr') if is_anim else None
eff_style = anim_style or page_style or \
(settings.get(f'plugin_{reg_key}_transition_style') if reg_key else None) or \
settings.get('transition_style', 'ltr')
eff_speed = page_speed if page_speed is not None else int(settings.get('transition_speed', 15))
last_transition_style = eff_style
last_transition_speed = eff_speed
if is_anim or page_text != last_sent_page:
max_dist = send_to_display(page_text, page_order, raw=is_anim, step_delay_ms=page_speed)
max_dist = _send_with_effect(page_text, anim_style or page_style, page_speed, is_anim, app_id=reg_key)
last_sent_page = page_text

rotation_time = max_dist * (4.0 / 64.0)
Expand Down Expand Up @@ -1257,7 +1400,9 @@ def current_state():
return jsonify(is_homed=is_homed, state=current_display_string, active_app=active_app,
active_app_playlist=active_app_playlist is not None,
app_playlist_name=app_playlist_name,
rows=get_rows(), cols=get_cols(), sim_mode=sim_mode, hardware_connected=ser is not None)
rows=get_rows(), cols=get_cols(), sim_mode=sim_mode, hardware_connected=ser is not None,
transition_style=last_transition_style,
transition_speed=last_transition_speed)

@app.route('/grid_config')
def grid_config():
Expand Down
Loading