mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 08:48:05 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd7e16e58e | ||
|
|
47e3021fc3 | ||
|
|
e319540c6e |
@@ -0,0 +1,847 @@
|
|||||||
|
"""
|
||||||
|
Plugin Composer blueprint — drag-and-drop plugin builder for LEDMatrix.
|
||||||
|
|
||||||
|
Routes:
|
||||||
|
GET /composer/ — Composer page
|
||||||
|
POST /composer/api/generate — Generate and return plugin ZIP
|
||||||
|
POST /composer/api/install — Write plugin directly to plugins_dir
|
||||||
|
GET /composer/api/fonts/<name> — Serve TTF font files for canvas rendering
|
||||||
|
GET /composer/api/validate-id/<id> — Check if a plugin ID is already taken
|
||||||
|
"""
|
||||||
|
import ast
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import zipfile
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import jinja2
|
||||||
|
import jsonschema
|
||||||
|
from flask import Blueprint, jsonify, render_template, request, send_file
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
composer_bp = Blueprint('composer', __name__)
|
||||||
|
|
||||||
|
# Module-level attributes injected by app.py at registration time
|
||||||
|
composer_bp.config_manager = None
|
||||||
|
composer_bp.plugin_manager = None
|
||||||
|
composer_bp.plugins_dir = None
|
||||||
|
composer_bp.project_root = None
|
||||||
|
|
||||||
|
# Fonts safe to serve to the browser for canvas rendering
|
||||||
|
_ALLOWED_FONTS = frozenset({'PressStart2P-Regular.ttf', '4x6-font.ttf', '5by7.regular.ttf'})
|
||||||
|
|
||||||
|
# Map composer font keys → DisplayManager attribute names
|
||||||
|
_FONT_ATTR_MAP = {
|
||||||
|
'press_start': 'regular_font',
|
||||||
|
'four_by_six': 'extra_small_font',
|
||||||
|
'five_by_seven': 'bdf_5x7_font',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Font sizes in LED pixels (used to compute second-line Y offsets)
|
||||||
|
_FONT_SIZE_MAP = {
|
||||||
|
'press_start': 8,
|
||||||
|
'four_by_six': 6,
|
||||||
|
'five_by_seven': 7,
|
||||||
|
}
|
||||||
|
|
||||||
|
_PLUGIN_ID_RE = re.compile(r'^[a-z][a-z0-9-]{0,62}$')
|
||||||
|
_PYTHON_IDENT_RE = re.compile(r'^[a-zA-Z_][a-zA-Z0-9_]*$')
|
||||||
|
|
||||||
|
# ── Jinja2 environment (separate from Flask's; autoescape=False for code gen) ──
|
||||||
|
|
||||||
|
_jinja_env: jinja2.Environment | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_jinja_env() -> jinja2.Environment:
|
||||||
|
global _jinja_env
|
||||||
|
if _jinja_env is None:
|
||||||
|
template_dir = Path(__file__).parent.parent / 'templates' / 'v3' / 'composer'
|
||||||
|
_jinja_env = jinja2.Environment(
|
||||||
|
loader=jinja2.FileSystemLoader(str(template_dir)),
|
||||||
|
autoescape=False,
|
||||||
|
trim_blocks=True,
|
||||||
|
lstrip_blocks=True,
|
||||||
|
)
|
||||||
|
_jinja_env.filters['as_rgb'] = _as_rgb_filter
|
||||||
|
_jinja_env.filters['as_fill'] = _as_fill_filter
|
||||||
|
return _jinja_env
|
||||||
|
|
||||||
|
|
||||||
|
def _as_rgb_filter(val) -> str:
|
||||||
|
"""[r, g, b] → '(r, g, b)'"""
|
||||||
|
if val is None:
|
||||||
|
return 'None'
|
||||||
|
return f'({int(val[0])}, {int(val[1])}, {int(val[2])})'
|
||||||
|
|
||||||
|
|
||||||
|
def _as_fill_filter(val) -> str:
|
||||||
|
"""[r, g, b] or None → '(r, g, b)' or 'None'"""
|
||||||
|
if val is None:
|
||||||
|
return 'None'
|
||||||
|
return _as_rgb_filter(val)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helper functions ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _to_class_name(name: str) -> str:
|
||||||
|
"""'My Clock' → 'MyClockPlugin' (avoids double-suffix if name already ends with Plugin)"""
|
||||||
|
words = re.sub(r'[^a-zA-Z0-9]', ' ', name).split()
|
||||||
|
base = ''.join(w.capitalize() for w in words)
|
||||||
|
return base if base.endswith('Plugin') else base + 'Plugin'
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_pos_expr(val: int, anchor: str | None, dim_var: str) -> str:
|
||||||
|
"""Produce a Python expression string for an anchored or fixed position.
|
||||||
|
|
||||||
|
anchor=None/'left'/'top' → fixed pixel value
|
||||||
|
anchor='center' → dim_var // 2 ± offset
|
||||||
|
anchor='right'/'bottom' → dim_var - offset
|
||||||
|
"""
|
||||||
|
if not anchor or anchor in ('left', 'top'):
|
||||||
|
return str(val)
|
||||||
|
if anchor in ('center', 'middle'):
|
||||||
|
if val == 0:
|
||||||
|
return f"{dim_var} // 2"
|
||||||
|
return f"{dim_var} // 2 + {val}" if val > 0 else f"{dim_var} // 2 - {abs(val)}"
|
||||||
|
if anchor in ('right', 'bottom'):
|
||||||
|
return dim_var if val == 0 else f"{dim_var} - {val}"
|
||||||
|
return str(val)
|
||||||
|
|
||||||
|
|
||||||
|
# Character widths in LED pixels per font (for text-alignment x offset math)
|
||||||
|
_FONT_CHAR_W = {
|
||||||
|
'press_start': 8,
|
||||||
|
'four_by_six': 4,
|
||||||
|
'five_by_seven': 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _aligned_x_expr(x_base_expr: str, text_align: str, char_count: int, char_w: int) -> str:
|
||||||
|
"""Return Python x expression for text alignment.
|
||||||
|
|
||||||
|
left → x_base_expr (no change)
|
||||||
|
center → x_base_expr - half_text_width
|
||||||
|
right → x_base_expr - text_width
|
||||||
|
"""
|
||||||
|
if text_align == 'left' or not text_align:
|
||||||
|
return x_base_expr
|
||||||
|
text_px = char_count * char_w
|
||||||
|
if text_align == 'center':
|
||||||
|
offset = text_px // 2
|
||||||
|
return f"({x_base_expr}) - {offset}" if offset else x_base_expr
|
||||||
|
if text_align == 'right':
|
||||||
|
return f"({x_base_expr}) - {text_px}" if text_px else x_base_expr
|
||||||
|
return x_base_expr
|
||||||
|
|
||||||
|
|
||||||
|
def _preprocess_elements(elements: list) -> list:
|
||||||
|
"""Expand raw element dicts into template-ready dicts with anchor expressions.
|
||||||
|
|
||||||
|
Invisible elements (visible=False) are excluded from generated code entirely.
|
||||||
|
"""
|
||||||
|
result = []
|
||||||
|
for el in elements:
|
||||||
|
# Skip hidden elements — they exist only in the preview
|
||||||
|
if el.get('visible') is False:
|
||||||
|
continue
|
||||||
|
|
||||||
|
p = dict(el)
|
||||||
|
t = el.get('type', '')
|
||||||
|
|
||||||
|
# Section elements are layer-list annotations only — no canvas output
|
||||||
|
if t == 'section':
|
||||||
|
continue
|
||||||
|
|
||||||
|
x_anchor = el.get('xAnchor') or None
|
||||||
|
y_anchor = el.get('yAnchor') or None
|
||||||
|
p['min_width'] = int(el.get('minWidth', 0) or 0)
|
||||||
|
|
||||||
|
if t in ('text', 'clock'):
|
||||||
|
font_key = el.get('font', 'press_start')
|
||||||
|
p['font_attr'] = _FONT_ATTR_MAP.get(font_key, 'regular_font')
|
||||||
|
p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 255)}, {el.get('b', 255)})"
|
||||||
|
text_align = el.get('textAlign', 'left')
|
||||||
|
raw_x = el.get('x', 0)
|
||||||
|
x_base_expr = _compute_pos_expr(raw_x, x_anchor, 'width')
|
||||||
|
p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
|
||||||
|
font_size = _FONT_SIZE_MAP.get(font_key, 8)
|
||||||
|
char_w = _FONT_CHAR_W.get(font_key, 8)
|
||||||
|
line_spacing = int(el.get('lineSpacing', 2))
|
||||||
|
y_expr = p['y_expr']
|
||||||
|
p['y2_expr'] = f"({y_expr}) + {font_size + line_spacing}"
|
||||||
|
if t == 'text':
|
||||||
|
t1 = el.get('text', '') or ''
|
||||||
|
t2 = el.get('text2', '') or ''
|
||||||
|
p['text2'] = t2
|
||||||
|
# Detect {variable} tokens — generate format_map() call instead of literal
|
||||||
|
_var_re = re.compile(r'\{([a-zA-Z_]\w*)\}')
|
||||||
|
p['text_is_template'] = bool(_var_re.search(t1) or _var_re.search(t2))
|
||||||
|
ref_len = max(len(t1), len(t2)) if t2 else len(t1)
|
||||||
|
p['x_expr'] = _aligned_x_expr(x_base_expr, text_align, ref_len, char_w)
|
||||||
|
p['x2_expr'] = p['x_expr'] # second line uses same x
|
||||||
|
else: # clock
|
||||||
|
fmt1 = el.get('format', '%H:%M') or '%H:%M'
|
||||||
|
fmt2 = el.get('format2', '') or ''
|
||||||
|
p['format2'] = fmt2
|
||||||
|
ref_len = max(len(fmt1), len(fmt2)) if fmt2 else len(fmt1)
|
||||||
|
p['x_expr'] = _aligned_x_expr(x_base_expr, text_align, ref_len, char_w)
|
||||||
|
p['x2_expr'] = p['x_expr']
|
||||||
|
p['blink'] = bool(el.get('blink', False))
|
||||||
|
|
||||||
|
elif t == 'dynamic_text':
|
||||||
|
binding = el.get('binding', {})
|
||||||
|
p['binding_source'] = binding.get('source', 'config')
|
||||||
|
p['binding_key'] = binding.get('key', '')
|
||||||
|
p['binding_format'] = binding.get('format')
|
||||||
|
font_key = el.get('font', 'press_start')
|
||||||
|
p['font_attr'] = _FONT_ATTR_MAP.get(font_key, 'regular_font')
|
||||||
|
p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 200)}, {el.get('b', 100)})"
|
||||||
|
x_base_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
|
||||||
|
p['x_expr'] = x_base_expr # dynamic text: runtime content determines width; use raw pos
|
||||||
|
p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
|
||||||
|
p['blink'] = bool(el.get('blink', False))
|
||||||
|
|
||||||
|
elif t == 'rectangle':
|
||||||
|
x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
|
||||||
|
y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
|
||||||
|
w = el.get('width', 10)
|
||||||
|
h = el.get('height', 8)
|
||||||
|
p['x_expr'] = x_expr
|
||||||
|
p['y_expr'] = y_expr
|
||||||
|
# x2/y2 as runtime expressions to support anchored positions
|
||||||
|
p['x2_expr'] = f"({x_expr}) + {w}"
|
||||||
|
p['y2_expr'] = f"({y_expr}) + {h}"
|
||||||
|
fill = (
|
||||||
|
[el.get('fillR', 0), el.get('fillG', 0), el.get('fillB', 128)]
|
||||||
|
if el.get('hasFill', True) else None
|
||||||
|
)
|
||||||
|
outline = (
|
||||||
|
[el.get('outR', 255), el.get('outG', 255), el.get('outB', 255)]
|
||||||
|
if el.get('hasOutline', True) else None
|
||||||
|
)
|
||||||
|
p['fill_tuple'] = _as_fill_filter(fill)
|
||||||
|
p['outline_tuple'] = _as_fill_filter(outline)
|
||||||
|
p['blink'] = bool(el.get('blink', False))
|
||||||
|
|
||||||
|
elif t in ('line', 'divider'):
|
||||||
|
if t == 'divider':
|
||||||
|
orient = el.get('orientation', 'horizontal')
|
||||||
|
if orient == 'horizontal':
|
||||||
|
y_val = el.get('y', 16)
|
||||||
|
y_expr = _compute_pos_expr(y_val, y_anchor, 'height')
|
||||||
|
p.update(x0_expr='0', y0_expr=y_expr, x1_expr='width - 1', y1_expr=y_expr)
|
||||||
|
else:
|
||||||
|
x_val = el.get('x', 64)
|
||||||
|
x_expr = _compute_pos_expr(x_val, x_anchor, 'width')
|
||||||
|
p.update(x0_expr=x_expr, y0_expr='0', x1_expr=x_expr, y1_expr='height - 1')
|
||||||
|
else:
|
||||||
|
p['x0_expr'] = _compute_pos_expr(el.get('x0', 0), x_anchor, 'width')
|
||||||
|
p['y0_expr'] = _compute_pos_expr(el.get('y0', 0), y_anchor, 'height')
|
||||||
|
p['x1_expr'] = str(el.get('x1', 127))
|
||||||
|
p['y1_expr'] = str(el.get('y1', 0))
|
||||||
|
p['rgb_tuple'] = f"({el.get('r', 180)}, {el.get('g', 180)}, {el.get('b', 180)})"
|
||||||
|
p['line_width'] = el.get('lineWidth', 1)
|
||||||
|
p['blink'] = bool(el.get('blink', False))
|
||||||
|
|
||||||
|
elif t == 'progress_bar':
|
||||||
|
p['x_expr'] = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
|
||||||
|
p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
|
||||||
|
p['bar_width'] = int(el.get('barWidth', 40))
|
||||||
|
p['bar_height'] = int(el.get('barHeight', 6))
|
||||||
|
binding = el.get('binding', {})
|
||||||
|
p['binding_key'] = binding.get('key', '')
|
||||||
|
p['fill_tuple'] = f"({el.get('r', 100)}, {el.get('g', 200)}, {el.get('b', 100)})"
|
||||||
|
bg = (
|
||||||
|
[el.get('bgR', 30), el.get('bgG', 30), el.get('bgB', 30)]
|
||||||
|
if el.get('hasBg', True) else None
|
||||||
|
)
|
||||||
|
outline = (
|
||||||
|
[el.get('outR', 100), el.get('outG', 100), el.get('outB', 100)]
|
||||||
|
if el.get('hasOutline', True) else None
|
||||||
|
)
|
||||||
|
p['bg_tuple'] = _as_fill_filter(bg)
|
||||||
|
p['outline_tuple'] = _as_fill_filter(outline)
|
||||||
|
p['blink'] = bool(el.get('blink', False))
|
||||||
|
|
||||||
|
elif t == 'arc':
|
||||||
|
x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
|
||||||
|
y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
|
||||||
|
w = el.get('width', 24)
|
||||||
|
h = el.get('height', 24)
|
||||||
|
p['x_expr'] = x_expr
|
||||||
|
p['y_expr'] = y_expr
|
||||||
|
p['x2_expr'] = f"({x_expr}) + {w}"
|
||||||
|
p['y2_expr'] = f"({y_expr}) + {h}"
|
||||||
|
p['start_angle'] = int(el.get('startAngle', 0))
|
||||||
|
p['end_angle'] = int(el.get('endAngle', 270))
|
||||||
|
p['line_width'] = max(1, int(el.get('lineWidth', 2)))
|
||||||
|
p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 200)}, {el.get('b', 0)})"
|
||||||
|
p['blink'] = bool(el.get('blink', False))
|
||||||
|
|
||||||
|
elif t == 'ellipse':
|
||||||
|
x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
|
||||||
|
y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
|
||||||
|
w = el.get('width', 24)
|
||||||
|
h = el.get('height', 12)
|
||||||
|
p['x_expr'] = x_expr
|
||||||
|
p['y_expr'] = y_expr
|
||||||
|
p['x2_expr'] = f"({x_expr}) + {w}"
|
||||||
|
p['y2_expr'] = f"({y_expr}) + {h}"
|
||||||
|
fill = (
|
||||||
|
[el.get('fillR', 0), el.get('fillG', 100), el.get('fillB', 200)]
|
||||||
|
if el.get('hasFill', True) else None
|
||||||
|
)
|
||||||
|
outline = (
|
||||||
|
[el.get('outR', 100), el.get('outG', 180), el.get('outB', 255)]
|
||||||
|
if el.get('hasOutline', True) else None
|
||||||
|
)
|
||||||
|
p['fill_tuple'] = _as_fill_filter(fill)
|
||||||
|
p['outline_tuple'] = _as_fill_filter(outline)
|
||||||
|
p['blink'] = bool(el.get('blink', False))
|
||||||
|
|
||||||
|
elif t == 'pixel':
|
||||||
|
p['x_expr'] = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
|
||||||
|
p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
|
||||||
|
p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 255)}, {el.get('b', 255)})"
|
||||||
|
p['blink'] = bool(el.get('blink', False))
|
||||||
|
|
||||||
|
elif t == 'rounded_rectangle':
|
||||||
|
x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
|
||||||
|
y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
|
||||||
|
w = el.get('width', 24)
|
||||||
|
h = el.get('height', 10)
|
||||||
|
p['x_expr'] = x_expr
|
||||||
|
p['y_expr'] = y_expr
|
||||||
|
p['x2_expr'] = f"({x_expr}) + {w}"
|
||||||
|
p['y2_expr'] = f"({y_expr}) + {h}"
|
||||||
|
p['border_radius'] = int(el.get('borderRadius', 3))
|
||||||
|
fill = (
|
||||||
|
[el.get('fillR', 0), el.get('fillG', 80), el.get('fillB', 180)]
|
||||||
|
if el.get('hasFill', True) else None
|
||||||
|
)
|
||||||
|
outline = (
|
||||||
|
[el.get('outR', 120), el.get('outG', 180), el.get('outB', 255)]
|
||||||
|
if el.get('hasOutline', True) else None
|
||||||
|
)
|
||||||
|
p['fill_tuple'] = _as_fill_filter(fill)
|
||||||
|
p['outline_tuple'] = _as_fill_filter(outline)
|
||||||
|
p['blink'] = bool(el.get('blink', False))
|
||||||
|
|
||||||
|
elif t == 'countdown':
|
||||||
|
font_key = el.get('font', 'four_by_six')
|
||||||
|
p['font_attr'] = _FONT_ATTR_MAP.get(font_key, 'extra_small_font')
|
||||||
|
p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 180)}, {el.get('b', 0)})"
|
||||||
|
binding = el.get('binding', {})
|
||||||
|
p['binding_key'] = binding.get('key', '')
|
||||||
|
p['countdown_format'] = el.get('countdownFormat', 'dh')
|
||||||
|
x_base_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
|
||||||
|
p['x_expr'] = x_base_expr
|
||||||
|
p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
|
||||||
|
p['blink'] = bool(el.get('blink', False))
|
||||||
|
|
||||||
|
elif t == 'pips':
|
||||||
|
p['x_expr'] = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
|
||||||
|
p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
|
||||||
|
p['pip_count'] = max(1, int(el.get('count', 5)))
|
||||||
|
p['pip_size'] = max(1, int(el.get('pipSize', 4)))
|
||||||
|
p['pip_spacing'] = max(0, int(el.get('pipSpacing', 2)))
|
||||||
|
p['show_empty'] = bool(el.get('showEmpty', True))
|
||||||
|
binding = el.get('binding', {})
|
||||||
|
p['binding_key'] = binding.get('key', '')
|
||||||
|
p['fill_tuple'] = f"({el.get('r', 255)}, {el.get('g', 200)}, {el.get('b', 0)})"
|
||||||
|
p['empty_tuple'] = f"({el.get('emptyR', 50)}, {el.get('emptyG', 50)}, {el.get('emptyB', 50)})"
|
||||||
|
p['blink'] = bool(el.get('blink', False))
|
||||||
|
|
||||||
|
elif t == 'sparkline':
|
||||||
|
x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
|
||||||
|
y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
|
||||||
|
p['x_expr'] = x_expr
|
||||||
|
p['y_expr'] = y_expr
|
||||||
|
p['bar_width_px'] = int(el.get('width', 40))
|
||||||
|
p['bar_height_px'] = int(el.get('height', 12))
|
||||||
|
p['bar_count'] = max(1, int(el.get('barCount', 8)))
|
||||||
|
p['bar_spacing'] = max(0, int(el.get('barSpacing', 1)))
|
||||||
|
binding = el.get('binding', {})
|
||||||
|
p['binding_key'] = binding.get('key', '')
|
||||||
|
p['fill_tuple'] = f"({el.get('r', 80)}, {el.get('g', 200)}, {el.get('b', 120)})"
|
||||||
|
bg = [el.get('bgR', 30), el.get('bgG', 30), el.get('bgB', 30)] if el.get('hasBg', False) else None
|
||||||
|
p['bg_tuple'] = _as_fill_filter(bg)
|
||||||
|
p['blink'] = bool(el.get('blink', False))
|
||||||
|
|
||||||
|
elif t == 'gauge':
|
||||||
|
x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
|
||||||
|
y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
|
||||||
|
w = el.get('width', 32)
|
||||||
|
h = el.get('height', 32)
|
||||||
|
p['x_expr'] = x_expr
|
||||||
|
p['y_expr'] = y_expr
|
||||||
|
p['x2_expr'] = f"({x_expr}) + {w}"
|
||||||
|
p['y2_expr'] = f"({y_expr}) + {h}"
|
||||||
|
p['start_angle'] = int(el.get('startAngle', 135))
|
||||||
|
p['end_angle'] = int(el.get('endAngle', 45))
|
||||||
|
p['line_width'] = max(1, int(el.get('lineWidth', 3)))
|
||||||
|
p['rgb_tuple'] = f"({el.get('r', 80)}, {el.get('g', 220)}, {el.get('b', 80)})"
|
||||||
|
track = (
|
||||||
|
[el.get('trackR', 40), el.get('trackG', 40), el.get('trackB', 40)]
|
||||||
|
if el.get('hasTrack', True) else None
|
||||||
|
)
|
||||||
|
p['track_tuple'] = _as_fill_filter(track)
|
||||||
|
binding = el.get('binding', {})
|
||||||
|
p['binding_key'] = binding.get('key', '')
|
||||||
|
font_key = el.get('font', 'four_by_six')
|
||||||
|
p['font_attr'] = _FONT_ATTR_MAP.get(font_key, 'extra_small_font')
|
||||||
|
p['show_label'] = bool(el.get('showLabel', True))
|
||||||
|
p['label_tuple'] = f"({el.get('labelR', 200)}, {el.get('labelG', 200)}, {el.get('labelB', 200)})"
|
||||||
|
p['blink'] = bool(el.get('blink', False))
|
||||||
|
|
||||||
|
elif t == 'marquee':
|
||||||
|
font_key = el.get('font', 'press_start')
|
||||||
|
p['font_attr'] = _FONT_ATTR_MAP.get(font_key, 'regular_font')
|
||||||
|
p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 255)}, {el.get('b', 255)})"
|
||||||
|
p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
|
||||||
|
p['text'] = el.get('text', 'Scrolling text')
|
||||||
|
p['char_w'] = _FONT_CHAR_W.get(font_key, 8)
|
||||||
|
p['gap'] = int(el.get('gap', 16))
|
||||||
|
p['scroll_speed'] = max(1, int(el.get('scrollSpeed', 1)))
|
||||||
|
p['direction'] = el.get('direction', 'left')
|
||||||
|
# Data key stored in self._data for stateful scrolling across display() calls
|
||||||
|
raw_id = str(el.get('id', 0)).replace('-', '_')
|
||||||
|
p['data_key'] = f"mq_{raw_id}"
|
||||||
|
p['blink'] = bool(el.get('blink', False))
|
||||||
|
|
||||||
|
result.append(p)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_plugin_files(data: dict) -> dict:
|
||||||
|
"""
|
||||||
|
Generate all plugin file contents as strings.
|
||||||
|
|
||||||
|
Returns dict: {'manager.py', 'manifest.json', 'config_schema.json', 'requirements.txt'}
|
||||||
|
Raises ValueError with a human-readable message on any validation failure.
|
||||||
|
"""
|
||||||
|
metadata = data.get('metadata', {})
|
||||||
|
elements = data.get('elements', [])
|
||||||
|
data_model = data.get('dataModel', {})
|
||||||
|
config_vars = data_model.get('configVars', [])
|
||||||
|
|
||||||
|
plugin_id = metadata.get('id', '').strip()
|
||||||
|
if not _PLUGIN_ID_RE.match(plugin_id):
|
||||||
|
raise ValueError(
|
||||||
|
'Plugin ID must start with a lowercase letter and contain only '
|
||||||
|
'lowercase letters, numbers, and hyphens (max 63 chars).'
|
||||||
|
)
|
||||||
|
|
||||||
|
plugin_name = metadata.get('name', '').strip()
|
||||||
|
if not plugin_name:
|
||||||
|
raise ValueError('Plugin name is required.')
|
||||||
|
|
||||||
|
author = metadata.get('author', '').strip()
|
||||||
|
if not author:
|
||||||
|
raise ValueError('Author is required.')
|
||||||
|
|
||||||
|
version = metadata.get('version', '1.0.0').strip()
|
||||||
|
|
||||||
|
# Validate config var keys are valid Python identifiers
|
||||||
|
for cv in config_vars:
|
||||||
|
key = cv.get('key', '')
|
||||||
|
if not _PYTHON_IDENT_RE.match(key):
|
||||||
|
raise ValueError(f'Config variable key "{key}" is not a valid Python identifier.')
|
||||||
|
|
||||||
|
class_name = _to_class_name(plugin_name)
|
||||||
|
# Only consider visible elements for code generation flags
|
||||||
|
visible_elements = [e for e in elements if e.get('visible') is not False]
|
||||||
|
processed = _preprocess_elements(elements)
|
||||||
|
has_clock = any(e.get('type') == 'clock' for e in visible_elements)
|
||||||
|
has_blink = any(e.get('blink') for e in visible_elements)
|
||||||
|
has_countdown = any(e.get('type') == 'countdown' for e in visible_elements)
|
||||||
|
_var_re = re.compile(r'\{[a-zA-Z_]\w*\}')
|
||||||
|
has_text_template = any(
|
||||||
|
e.get('type') == 'text' and (
|
||||||
|
_var_re.search(e.get('text', '') or '') or
|
||||||
|
_var_re.search(e.get('text2', '') or '')
|
||||||
|
)
|
||||||
|
for e in visible_elements
|
||||||
|
)
|
||||||
|
|
||||||
|
# Background fill color (None → don't render, use LED panel's native black)
|
||||||
|
bg_color: str | None = None
|
||||||
|
bg_raw = metadata.get('bgColor')
|
||||||
|
if isinstance(bg_raw, dict):
|
||||||
|
r, g, b = int(bg_raw.get('r', 0)), int(bg_raw.get('g', 0)), int(bg_raw.get('b', 0))
|
||||||
|
if r or g or b:
|
||||||
|
bg_color = f'({r}, {g}, {b})'
|
||||||
|
|
||||||
|
# Render manager.py
|
||||||
|
env = _get_jinja_env()
|
||||||
|
try:
|
||||||
|
tmpl = env.get_template('manager.py.j2')
|
||||||
|
except jinja2.TemplateNotFound:
|
||||||
|
raise ValueError('Code generation template not found. This is a server configuration issue.')
|
||||||
|
|
||||||
|
manager_py = tmpl.render(
|
||||||
|
plugin_name=plugin_name,
|
||||||
|
class_name=class_name,
|
||||||
|
plugin_id=plugin_id,
|
||||||
|
generated_date=datetime.now().strftime('%Y-%m-%d'),
|
||||||
|
config_vars=config_vars,
|
||||||
|
elements=processed,
|
||||||
|
has_clock=has_clock,
|
||||||
|
has_blink=has_blink,
|
||||||
|
has_countdown=has_countdown,
|
||||||
|
has_text_template=has_text_template,
|
||||||
|
bg_color=bg_color,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Syntax-check the generated Python
|
||||||
|
try:
|
||||||
|
ast.parse(manager_py)
|
||||||
|
except SyntaxError as exc:
|
||||||
|
raise ValueError(f'Generated code has a syntax error: {exc}') from exc
|
||||||
|
|
||||||
|
# Build manifest
|
||||||
|
manifest = {
|
||||||
|
'id': plugin_id,
|
||||||
|
'name': plugin_name,
|
||||||
|
'version': version,
|
||||||
|
'author': author,
|
||||||
|
'description': metadata.get('description', 'Custom plugin created with LEDMatrix Plugin Composer'),
|
||||||
|
'category': metadata.get('category', 'custom'),
|
||||||
|
'tags': ['composer', 'custom'],
|
||||||
|
'entry_point': 'manager.py',
|
||||||
|
'class_name': class_name,
|
||||||
|
'display_modes': [plugin_id],
|
||||||
|
'compatible_versions': ['>=2.0.0'],
|
||||||
|
'last_updated': datetime.now().strftime('%Y-%m-%d'),
|
||||||
|
'update_interval': int(metadata.get('update_interval', 60)),
|
||||||
|
'default_duration': float(metadata.get('display_duration', 15)),
|
||||||
|
'versions': [
|
||||||
|
{'released': datetime.now().strftime('%Y-%m-%d'), 'version': version}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Validate manifest against the project's schema
|
||||||
|
if composer_bp.project_root:
|
||||||
|
schema_path = Path(composer_bp.project_root) / 'schema' / 'manifest_schema.json'
|
||||||
|
if schema_path.exists():
|
||||||
|
schema = json.loads(schema_path.read_text())
|
||||||
|
validator = jsonschema.Draft7Validator(schema)
|
||||||
|
errors = list(validator.iter_errors(manifest))
|
||||||
|
if errors:
|
||||||
|
msgs = '; '.join(e.message for e in errors[:3])
|
||||||
|
raise ValueError(f'Manifest validation failed: {msgs}')
|
||||||
|
|
||||||
|
# Build config_schema
|
||||||
|
type_map = {
|
||||||
|
'string': {'type': 'string'},
|
||||||
|
'number': {'type': 'number', 'minimum': 0},
|
||||||
|
'boolean': {'type': 'boolean'},
|
||||||
|
'color': {
|
||||||
|
'type': 'array',
|
||||||
|
'items': {'type': 'integer', 'minimum': 0, 'maximum': 255},
|
||||||
|
'minItems': 3,
|
||||||
|
'maxItems': 3,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
config_properties = {
|
||||||
|
'enabled': {'type': 'boolean', 'default': True},
|
||||||
|
'display_duration': {'type': 'number', 'minimum': 1, 'default': float(metadata.get('display_duration', 15))},
|
||||||
|
}
|
||||||
|
for cv in config_vars:
|
||||||
|
cv_type = cv.get('type', 'string')
|
||||||
|
prop = dict(type_map.get(cv_type, {'type': 'string'}))
|
||||||
|
if cv.get('description'):
|
||||||
|
prop['description'] = cv['description']
|
||||||
|
if cv.get('label'):
|
||||||
|
prop['title'] = cv['label']
|
||||||
|
default = cv.get('default', '')
|
||||||
|
if cv_type == 'number':
|
||||||
|
try:
|
||||||
|
prop['default'] = float(default) if default != '' else 0
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
prop['default'] = 0
|
||||||
|
elif cv_type == 'boolean':
|
||||||
|
prop['default'] = bool(default)
|
||||||
|
else:
|
||||||
|
prop['default'] = default
|
||||||
|
config_properties[cv['key']] = prop
|
||||||
|
|
||||||
|
config_schema = {
|
||||||
|
'$schema': 'http://json-schema.org/draft-07/schema#',
|
||||||
|
'type': 'object',
|
||||||
|
'properties': config_properties,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'manager.py': manager_py,
|
||||||
|
'manifest.json': json.dumps(manifest, indent=2),
|
||||||
|
'config_schema.json': json.dumps(config_schema, indent=2),
|
||||||
|
'requirements.txt': '',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_composer_state(target_dir: Path, payload: dict) -> None:
|
||||||
|
"""Persist the raw composer payload alongside the generated plugin files."""
|
||||||
|
(target_dir / '_composer_state.json').write_text(
|
||||||
|
json.dumps(payload, indent=2, ensure_ascii=False), encoding='utf-8'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _pack_zip(files: dict, plugin_id: str) -> io.BytesIO:
|
||||||
|
"""Pack generated plugin files into an in-memory ZIP."""
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, 'w', compression=zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for filename, content in files.items():
|
||||||
|
info = zipfile.ZipInfo(f'{plugin_id}/{filename}')
|
||||||
|
info.compress_type = zipfile.ZIP_DEFLATED
|
||||||
|
zf.writestr(info, content.encode('utf-8') if isinstance(content, str) else content)
|
||||||
|
buf.seek(0)
|
||||||
|
return buf
|
||||||
|
|
||||||
|
|
||||||
|
# ── Routes ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@composer_bp.route('/')
|
||||||
|
def index():
|
||||||
|
return render_template('v3/composer.html')
|
||||||
|
|
||||||
|
|
||||||
|
@composer_bp.route('/api/generate', methods=['POST'])
|
||||||
|
def generate_zip():
|
||||||
|
data = request.get_json(force=True, silent=True)
|
||||||
|
if not data:
|
||||||
|
return jsonify({'status': 'error', 'message': 'No JSON body'}), 400
|
||||||
|
try:
|
||||||
|
files = _generate_plugin_files(data)
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({'status': 'error', 'message': str(exc)}), 422
|
||||||
|
|
||||||
|
plugin_id = data.get('metadata', {}).get('id', 'plugin')
|
||||||
|
files['_composer_state.json'] = json.dumps(data, indent=2, ensure_ascii=False)
|
||||||
|
zip_buf = _pack_zip(files, plugin_id)
|
||||||
|
return send_file(
|
||||||
|
zip_buf,
|
||||||
|
mimetype='application/zip',
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=f'{plugin_id}.zip',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@composer_bp.route('/api/install', methods=['POST'])
|
||||||
|
def install_locally():
|
||||||
|
if not composer_bp.plugins_dir:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Plugin directory not configured'}), 503
|
||||||
|
|
||||||
|
data = request.get_json(force=True, silent=True)
|
||||||
|
if not data:
|
||||||
|
return jsonify({'status': 'error', 'message': 'No JSON body'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
files = _generate_plugin_files(data)
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({'status': 'error', 'message': str(exc)}), 422
|
||||||
|
|
||||||
|
plugin_id = data.get('metadata', {}).get('id', '')
|
||||||
|
# _generate_plugin_files() above already validates metadata.id via this
|
||||||
|
# same regex before it will return, but that guarantee lives in a
|
||||||
|
# different function -- re-check here, at the point the path is actually
|
||||||
|
# built, so this route stays safe on its own if that call is ever
|
||||||
|
# reordered or changed.
|
||||||
|
if not _PLUGIN_ID_RE.match(plugin_id):
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid plugin ID'}), 400
|
||||||
|
target = Path(composer_bp.plugins_dir) / plugin_id
|
||||||
|
force = bool(data.get('_force', False))
|
||||||
|
|
||||||
|
if target.exists() and not force:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'conflict',
|
||||||
|
'message': f'Plugin "{plugin_id}" is already installed.',
|
||||||
|
}), 409
|
||||||
|
|
||||||
|
try:
|
||||||
|
if target.exists() and force:
|
||||||
|
import shutil as _shutil
|
||||||
|
_shutil.rmtree(target)
|
||||||
|
target.mkdir(parents=True, exist_ok=False)
|
||||||
|
for filename, content in files.items():
|
||||||
|
(target / filename).write_text(content, encoding='utf-8')
|
||||||
|
_save_composer_state(target, data)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.error('Failed to write plugin files for %s: %s', plugin_id, exc)
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to write plugin files'}), 500
|
||||||
|
|
||||||
|
# Trigger plugin discovery so it shows up in the Plugin Manager immediately
|
||||||
|
if composer_bp.plugin_manager:
|
||||||
|
try:
|
||||||
|
composer_bp.plugin_manager.discover_plugins()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning('discover_plugins() failed after composer install: %s', exc)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': f'Plugin "{plugin_id}" installed successfully.',
|
||||||
|
'plugin_id': plugin_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@composer_bp.route('/api/fonts/<font_name>')
|
||||||
|
def serve_font(font_name):
|
||||||
|
"""Serve an allowlisted font file for canvas FontFace loading."""
|
||||||
|
if font_name not in _ALLOWED_FONTS:
|
||||||
|
return '', 404
|
||||||
|
if not composer_bp.project_root:
|
||||||
|
return '', 503
|
||||||
|
font_path = Path(composer_bp.project_root) / 'assets' / 'fonts' / font_name
|
||||||
|
if not font_path.exists():
|
||||||
|
return '', 404
|
||||||
|
return send_file(str(font_path), mimetype='font/ttf')
|
||||||
|
|
||||||
|
|
||||||
|
@composer_bp.route('/api/validate-id/<plugin_id>')
|
||||||
|
def validate_id(plugin_id):
|
||||||
|
"""Check whether a plugin ID is valid and available."""
|
||||||
|
if not _PLUGIN_ID_RE.match(plugin_id):
|
||||||
|
return jsonify({'valid': False, 'available': False, 'reason': 'Invalid format'})
|
||||||
|
if composer_bp.plugins_dir:
|
||||||
|
taken = (Path(composer_bp.plugins_dir) / plugin_id).exists()
|
||||||
|
if taken:
|
||||||
|
return jsonify({'valid': True, 'available': False, 'reason': 'Already installed'})
|
||||||
|
return jsonify({'valid': True, 'available': True})
|
||||||
|
|
||||||
|
|
||||||
|
@composer_bp.route('/api/plugins')
|
||||||
|
def list_plugins():
|
||||||
|
"""List installed plugins, flagging which ones have a saved composer state."""
|
||||||
|
if not composer_bp.plugins_dir:
|
||||||
|
return jsonify([])
|
||||||
|
plugins_dir = Path(composer_bp.plugins_dir)
|
||||||
|
results = []
|
||||||
|
for entry in sorted(plugins_dir.iterdir()):
|
||||||
|
if not entry.is_dir():
|
||||||
|
continue
|
||||||
|
manifest_path = entry / 'manifest.json'
|
||||||
|
if not manifest_path.exists():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
manifest = json.loads(manifest_path.read_text())
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Skipping %s: unreadable manifest.json (%s)", entry.name, e)
|
||||||
|
continue
|
||||||
|
has_state = (entry / '_composer_state.json').exists()
|
||||||
|
results.append({
|
||||||
|
'id': manifest.get('id', entry.name),
|
||||||
|
'name': manifest.get('name', entry.name),
|
||||||
|
'version': manifest.get('version', ''),
|
||||||
|
'author': manifest.get('author', ''),
|
||||||
|
'has_composer_state': has_state,
|
||||||
|
})
|
||||||
|
return jsonify(results)
|
||||||
|
|
||||||
|
|
||||||
|
@composer_bp.route('/api/preview', methods=['POST'])
|
||||||
|
def preview_code():
|
||||||
|
"""Generate plugin files and return them as JSON for the code preview modal."""
|
||||||
|
data = request.get_json(force=True, silent=True)
|
||||||
|
if not data:
|
||||||
|
return jsonify({'status': 'error', 'message': 'No JSON body'}), 400
|
||||||
|
try:
|
||||||
|
files = _generate_plugin_files(data)
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({'status': 'error', 'message': str(exc)}), 422
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'files': {
|
||||||
|
'manager.py': files['manager.py'],
|
||||||
|
'manifest.json': files['manifest.json'],
|
||||||
|
'config_schema.json': files['config_schema.json'],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@composer_bp.route('/api/load/<plugin_id>')
|
||||||
|
def load_plugin(plugin_id):
|
||||||
|
"""Load a plugin's composer state for editing.
|
||||||
|
|
||||||
|
If a _composer_state.json exists, return it verbatim.
|
||||||
|
Otherwise, extract config vars from config_schema.json for a partial import.
|
||||||
|
"""
|
||||||
|
if not composer_bp.plugins_dir:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Plugin directory not configured'}), 503
|
||||||
|
if not _PLUGIN_ID_RE.match(plugin_id):
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid plugin ID'}), 400
|
||||||
|
|
||||||
|
plugin_dir = Path(composer_bp.plugins_dir) / plugin_id
|
||||||
|
if not plugin_dir.exists():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404
|
||||||
|
|
||||||
|
# Full composer state
|
||||||
|
state_path = plugin_dir / '_composer_state.json'
|
||||||
|
if state_path.exists():
|
||||||
|
try:
|
||||||
|
state = json.loads(state_path.read_text())
|
||||||
|
return jsonify({'status': 'ok', 'source': 'composer', 'state': state})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error('Failed to read composer state for %s: %s', plugin_id, exc)
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to read state'}), 500
|
||||||
|
|
||||||
|
# Partial import from config_schema.json
|
||||||
|
schema_path = plugin_dir / 'config_schema.json'
|
||||||
|
manifest_path = plugin_dir / 'manifest.json'
|
||||||
|
config_vars = []
|
||||||
|
|
||||||
|
if schema_path.exists():
|
||||||
|
try:
|
||||||
|
schema = json.loads(schema_path.read_text())
|
||||||
|
props = schema.get('properties', {})
|
||||||
|
skip = {'enabled', 'display_duration', 'update_interval'}
|
||||||
|
type_map = {'boolean': 'boolean', 'number': 'number', 'integer': 'number', 'string': 'string'}
|
||||||
|
for key, prop in props.items():
|
||||||
|
if key in skip:
|
||||||
|
continue
|
||||||
|
prop_type = prop.get('type', 'string')
|
||||||
|
if isinstance(prop_type, list):
|
||||||
|
prop_type = next((t for t in prop_type if t != 'null'), 'string')
|
||||||
|
# Detect color arrays
|
||||||
|
if prop_type == 'array' and prop.get('maxItems') == 3:
|
||||||
|
cv_type = 'color'
|
||||||
|
else:
|
||||||
|
cv_type = type_map.get(prop_type, 'string')
|
||||||
|
config_vars.append({
|
||||||
|
'key': key,
|
||||||
|
'label': prop.get('title', key.replace('_', ' ').title()),
|
||||||
|
'type': cv_type,
|
||||||
|
'default': prop.get('default', ''),
|
||||||
|
'description': prop.get('description', ''),
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to parse config_schema.json for %s: %s", plugin_id, e)
|
||||||
|
|
||||||
|
manifest = {}
|
||||||
|
if manifest_path.exists():
|
||||||
|
try:
|
||||||
|
manifest = json.loads(manifest_path.read_text())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
partial_state = {
|
||||||
|
'composer_version': '1.0',
|
||||||
|
'metadata': {
|
||||||
|
'id': manifest.get('id', plugin_id),
|
||||||
|
'name': manifest.get('name', plugin_id),
|
||||||
|
'author': manifest.get('author', ''),
|
||||||
|
'version': manifest.get('version', '1.0.0'),
|
||||||
|
'description': manifest.get('description', ''),
|
||||||
|
'category': manifest.get('category', 'custom'),
|
||||||
|
'display_duration': manifest.get('default_duration', 15),
|
||||||
|
'update_interval': manifest.get('update_interval', 60),
|
||||||
|
'api_requirements': manifest.get('api_requirements', []),
|
||||||
|
},
|
||||||
|
'elements': [],
|
||||||
|
'dataModel': {'configVars': config_vars, 'dataSources': [], 'computedVars': []},
|
||||||
|
}
|
||||||
|
return jsonify({'status': 'ok', 'source': 'schema_import', 'state': partial_state})
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,757 @@
|
|||||||
|
/**
|
||||||
|
* ComposerCanvas — stateless LED matrix canvas renderer.
|
||||||
|
*
|
||||||
|
* Coordinate system: LED pixels (integers). All drawing multiplies by SCALE.
|
||||||
|
* PIL draw.text(x,y) is top-left; canvas fillText(x,y) is baseline.
|
||||||
|
* → Canvas text cy = (actualY + fontSizePx) * SCALE
|
||||||
|
*
|
||||||
|
* Anchors: element x/y are offsets from their anchor point:
|
||||||
|
* xAnchor=null/'left' → x is fixed offset from left
|
||||||
|
* xAnchor='center' → x is offset from width/2
|
||||||
|
* xAnchor='right' → x is offset inward from right edge
|
||||||
|
* yAnchor follows the same pattern with 'top'/'middle'/'bottom'
|
||||||
|
*
|
||||||
|
* Breakpoints: elements with minWidth > currentMatrixW are rendered at 25% opacity.
|
||||||
|
*
|
||||||
|
* Resize handles: drawn on selected rectangles; 8 handles (corners + edge mids).
|
||||||
|
*/
|
||||||
|
window.ComposerCanvas = (() => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let _canvas = null;
|
||||||
|
let _ctx = null;
|
||||||
|
let _showGrid = true;
|
||||||
|
|
||||||
|
const DISPLAY_PRESETS = [
|
||||||
|
{ label: '64×32', w: 64, h: 32 },
|
||||||
|
{ label: '128×32', w: 128, h: 32 },
|
||||||
|
{ label: '128×64', w: 128, h: 64 },
|
||||||
|
{ label: '256×32', w: 256, h: 32 },
|
||||||
|
{ label: '256×64', w: 256, h: 64 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const FONT_MAP = {
|
||||||
|
press_start: { family: "'PressStart2P', monospace", sizePx: 8, charW: 8 },
|
||||||
|
four_by_six: { family: 'monospace', sizePx: 6, charW: 4 },
|
||||||
|
five_by_seven: { family: 'monospace', sizePx: 7, charW: 5 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const ELEMENT_DEFAULTS = {
|
||||||
|
text: {
|
||||||
|
text: 'Hello', font: 'press_start',
|
||||||
|
r: 255, g: 255, b: 255,
|
||||||
|
text2: '', lineSpacing: 2, textAlign: 'left',
|
||||||
|
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||||
|
},
|
||||||
|
dynamic_text: {
|
||||||
|
binding: { source: 'config', key: '', format: null },
|
||||||
|
font: 'press_start', textAlign: 'left',
|
||||||
|
r: 255, g: 200, b: 100,
|
||||||
|
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||||
|
},
|
||||||
|
clock: {
|
||||||
|
format: '%H:%M', font: 'press_start',
|
||||||
|
r: 100, g: 255, b: 100,
|
||||||
|
format2: '', lineSpacing: 2, textAlign: 'left',
|
||||||
|
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||||
|
},
|
||||||
|
rectangle: {
|
||||||
|
width: 20, height: 8,
|
||||||
|
fillR: 0, fillG: 0, fillB: 128, hasFill: true,
|
||||||
|
outR: 255, outG: 255, outB: 255, hasOutline: true,
|
||||||
|
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||||
|
},
|
||||||
|
ellipse: {
|
||||||
|
width: 24, height: 12,
|
||||||
|
fillR: 0, fillG: 100, fillB: 200, hasFill: true,
|
||||||
|
outR: 100, outG: 180, outB: 255, hasOutline: true,
|
||||||
|
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||||
|
},
|
||||||
|
arc: {
|
||||||
|
width: 24, height: 24,
|
||||||
|
startAngle: 0, endAngle: 270, lineWidth: 2,
|
||||||
|
r: 255, g: 200, b: 0,
|
||||||
|
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||||
|
},
|
||||||
|
pixel: {
|
||||||
|
r: 255, g: 255, b: 255,
|
||||||
|
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||||
|
},
|
||||||
|
rounded_rectangle: {
|
||||||
|
width: 24, height: 10, borderRadius: 3,
|
||||||
|
fillR: 0, fillG: 80, fillB: 180, hasFill: true,
|
||||||
|
outR: 120, outG: 180, outB: 255, hasOutline: true,
|
||||||
|
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||||
|
},
|
||||||
|
line: {
|
||||||
|
x0: 0, y0: 16, x1: 63, y1: 16,
|
||||||
|
r: 180, g: 180, b: 180, lineWidth: 1,
|
||||||
|
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||||
|
},
|
||||||
|
divider: {
|
||||||
|
orientation: 'horizontal', y: 16, x: 64,
|
||||||
|
r: 100, g: 100, b: 100,
|
||||||
|
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||||
|
},
|
||||||
|
progress_bar: {
|
||||||
|
barWidth: 60, barHeight: 6,
|
||||||
|
binding: { source: 'config', key: '', format: null },
|
||||||
|
r: 80, g: 200, b: 80,
|
||||||
|
bgR: 30, bgG: 30, bgB: 30, hasBg: true,
|
||||||
|
outR: 100, outG: 100, outB: 100, hasOutline: true,
|
||||||
|
previewPct: 65,
|
||||||
|
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||||
|
},
|
||||||
|
countdown: {
|
||||||
|
binding: { source: 'config', key: '', format: null },
|
||||||
|
countdownFormat: 'dh',
|
||||||
|
font: 'four_by_six', textAlign: 'left',
|
||||||
|
r: 255, g: 180, b: 0,
|
||||||
|
previewText: '42d 3h',
|
||||||
|
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||||
|
},
|
||||||
|
marquee: {
|
||||||
|
text: 'Scrolling text', font: 'press_start',
|
||||||
|
r: 255, g: 255, b: 255,
|
||||||
|
scrollSpeed: 1, gap: 16, direction: 'left',
|
||||||
|
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
label: 'Section',
|
||||||
|
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||||
|
},
|
||||||
|
pips: {
|
||||||
|
count: 5, filled: 3, pipSize: 4, pipSpacing: 2,
|
||||||
|
r: 255, g: 200, b: 0,
|
||||||
|
emptyR: 50, emptyG: 50, emptyB: 50, showEmpty: true,
|
||||||
|
binding: { source: 'config', key: '', format: null },
|
||||||
|
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||||
|
},
|
||||||
|
sparkline: {
|
||||||
|
width: 40, height: 12,
|
||||||
|
barCount: 8, barSpacing: 1,
|
||||||
|
r: 80, g: 200, b: 120,
|
||||||
|
bgR: 30, bgG: 30, bgB: 30, hasBg: false,
|
||||||
|
binding: { source: 'config', key: '', format: null },
|
||||||
|
previewData: '0.3,0.6,0.4,0.8,0.5,0.9,0.7,0.85',
|
||||||
|
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||||
|
},
|
||||||
|
gauge: {
|
||||||
|
width: 32, height: 32,
|
||||||
|
startAngle: 135, endAngle: 45, lineWidth: 3,
|
||||||
|
binding: { source: 'config', key: '', format: null },
|
||||||
|
r: 80, g: 220, b: 80,
|
||||||
|
trackR: 40, trackG: 40, trackB: 40, hasTrack: true,
|
||||||
|
showLabel: true, font: 'four_by_six', labelR: 200, labelG: 200, labelB: 200,
|
||||||
|
previewPct: 65,
|
||||||
|
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Anchor resolution ────────────────────────────────────────────────
|
||||||
|
function resolveAnchor(val, anchor, dim) {
|
||||||
|
if (!anchor || anchor === 'left' || anchor === 'top') return val;
|
||||||
|
if (anchor === 'center' || anchor === 'middle') return Math.floor(dim / 2) + val;
|
||||||
|
if (anchor === 'right' || anchor === 'bottom') return dim - val;
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeActualPos(el, matrixW, matrixH) {
|
||||||
|
const ax = resolveAnchor(el.x ?? el.x0 ?? 0, el.xAnchor, matrixW);
|
||||||
|
const ay = resolveAnchor(el.y ?? el.y0 ?? 0, el.yAnchor, matrixH);
|
||||||
|
return { x: ax, y: ay };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bounding box (LED pixel space) ──────────────────────────────────
|
||||||
|
function getBoundingBox(el, matrixW, matrixH) {
|
||||||
|
const { x: ax, y: ay } = computeActualPos(el, matrixW, matrixH);
|
||||||
|
const finfo = FONT_MAP[el.font] || FONT_MAP.press_start;
|
||||||
|
|
||||||
|
switch (el.type) {
|
||||||
|
case 'text': {
|
||||||
|
const t1 = el.text || '', t2 = el.text2 || '';
|
||||||
|
const w = Math.max(t1.length, t2.length) * finfo.charW;
|
||||||
|
const h = t2 ? finfo.sizePx * 2 + (el.lineSpacing ?? 2) : finfo.sizePx;
|
||||||
|
const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax;
|
||||||
|
return { x: bx, y: ay, w, h };
|
||||||
|
}
|
||||||
|
case 'dynamic_text': {
|
||||||
|
const key = el.binding?.key || '?';
|
||||||
|
const w = (`{${key}}`).length * finfo.charW;
|
||||||
|
const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax;
|
||||||
|
return { x: bx, y: ay, w, h: finfo.sizePx };
|
||||||
|
}
|
||||||
|
case 'clock': {
|
||||||
|
const t1 = el.format || '%H:%M', t2 = el.format2 || '';
|
||||||
|
const w = Math.max(t1.length, t2.length) * finfo.charW;
|
||||||
|
const h = t2 ? finfo.sizePx * 2 + (el.lineSpacing ?? 2) : finfo.sizePx;
|
||||||
|
const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax;
|
||||||
|
return { x: bx, y: ay, w, h };
|
||||||
|
}
|
||||||
|
case 'countdown': {
|
||||||
|
const pt = el.previewText || '--d --h';
|
||||||
|
const w = pt.length * finfo.charW;
|
||||||
|
const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax;
|
||||||
|
return { x: bx, y: ay, w, h: finfo.sizePx };
|
||||||
|
}
|
||||||
|
case 'rectangle':
|
||||||
|
case 'rounded_rectangle':
|
||||||
|
case 'ellipse':
|
||||||
|
case 'arc':
|
||||||
|
return { x: ax, y: ay, w: el.width, h: el.height };
|
||||||
|
case 'pixel':
|
||||||
|
return { x: ax, y: ay, w: 1, h: 1 };
|
||||||
|
case 'line':
|
||||||
|
return {
|
||||||
|
x: Math.min(el.x0, el.x1), y: Math.min(el.y0, el.y1),
|
||||||
|
w: Math.max(1, Math.abs(el.x1 - el.x0)),
|
||||||
|
h: Math.max(1, Math.abs(el.y1 - el.y0)),
|
||||||
|
};
|
||||||
|
case 'divider':
|
||||||
|
return el.orientation === 'horizontal'
|
||||||
|
? { x: 0, y: ay, w: matrixW, h: 1 }
|
||||||
|
: { x: ax, y: 0, w: 1, h: matrixH };
|
||||||
|
case 'progress_bar':
|
||||||
|
return { x: ax, y: ay, w: el.barWidth ?? 60, h: el.barHeight ?? 6 };
|
||||||
|
case 'marquee': {
|
||||||
|
const mfinfo = FONT_MAP[el.font] || FONT_MAP.press_start;
|
||||||
|
return { x: 0, y: ay, w: matrixW, h: mfinfo.sizePx };
|
||||||
|
}
|
||||||
|
case 'gauge':
|
||||||
|
return { x: ax, y: ay, w: el.width ?? 32, h: el.height ?? 32 };
|
||||||
|
case 'sparkline':
|
||||||
|
return { x: ax, y: ay, w: el.width ?? 40, h: el.height ?? 12 };
|
||||||
|
case 'pips': {
|
||||||
|
const pc = el.count ?? 5, ps = el.pipSize ?? 4, pg = el.pipSpacing ?? 2;
|
||||||
|
return { x: ax, y: ay, w: pc * ps + (pc - 1) * pg, h: ps };
|
||||||
|
}
|
||||||
|
case 'section':
|
||||||
|
return { x: ax, y: ay, w: 0, h: 0 };
|
||||||
|
default:
|
||||||
|
return { x: ax, y: ay, w: 4, h: 4 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Resize handle support ─────────────────────────────────────────────
|
||||||
|
// Returns 8 handle points for a rectangle in LED pixel space
|
||||||
|
function _getRectHandles(el, matrixW, matrixH) {
|
||||||
|
const { x: ax, y: ay } = computeActualPos(el, matrixW, matrixH);
|
||||||
|
const w = el.width, h = el.height;
|
||||||
|
const cx = ax + w / 2, cy = ay + h / 2;
|
||||||
|
return {
|
||||||
|
nw: { x: ax, y: ay },
|
||||||
|
n: { x: cx, y: ay },
|
||||||
|
ne: { x: ax + w, y: ay },
|
||||||
|
w: { x: ax, y: cy },
|
||||||
|
e: { x: ax + w, y: cy },
|
||||||
|
sw: { x: ax, y: ay + h },
|
||||||
|
s: { x: cx, y: ay + h },
|
||||||
|
se: { x: ax + w, y: ay + h },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the handle direction under LED-space point (lx, ly), or null
|
||||||
|
function getResizeHandle(el, lx, ly, matrixW, matrixH) {
|
||||||
|
if (!['rectangle', 'rounded_rectangle', 'ellipse', 'arc', 'gauge', 'sparkline'].includes(el.type)) return null;
|
||||||
|
const handles = _getRectHandles(el, matrixW, matrixH);
|
||||||
|
const PAD = 4;
|
||||||
|
for (const [dir, pt] of Object.entries(handles)) {
|
||||||
|
if (Math.abs(lx - pt.x) <= PAD && Math.abs(ly - pt.y) <= PAD) return dir;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _HANDLE_CURSORS = {
|
||||||
|
nw: 'nw-resize', n: 'n-resize', ne: 'ne-resize',
|
||||||
|
w: 'w-resize', e: 'e-resize',
|
||||||
|
sw: 'sw-resize', s: 's-resize', se: 'se-resize',
|
||||||
|
};
|
||||||
|
function getCursorForHandle(handle) {
|
||||||
|
return _HANDLE_CURSORS[handle] || 'crosshair';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hit test ─────────────────────────────────────────────────────────
|
||||||
|
function hitTest(el, lx, ly, matrixW, matrixH) {
|
||||||
|
const PAD = 3;
|
||||||
|
const bb = getBoundingBox(el, matrixW, matrixH);
|
||||||
|
return (
|
||||||
|
lx >= bb.x - PAD && lx <= bb.x + bb.w + PAD &&
|
||||||
|
ly >= bb.y - PAD && ly <= bb.y + bb.h + PAD
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Draw a single element ─────────────────────────────────────────────
|
||||||
|
function _drawElement(ctx, el, SCALE, matrixW, matrixH, opts = {}) {
|
||||||
|
const s = SCALE;
|
||||||
|
const { x: ax, y: ay } = computeActualPos(el, matrixW, matrixH);
|
||||||
|
const belowBreakpoint = el.minWidth > 0 && matrixW < el.minWidth;
|
||||||
|
const hidden = el.visible === false;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
if (hidden) ctx.globalAlpha = 0.12;
|
||||||
|
else if (belowBreakpoint) ctx.globalAlpha = 0.25;
|
||||||
|
|
||||||
|
// Blink animation: when blinkOff, fully hide blinking elements
|
||||||
|
if (el.blink) {
|
||||||
|
if (opts.blinkOff) { ctx.restore(); return; }
|
||||||
|
ctx.globalAlpha *= 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: compute draw X for text alignment
|
||||||
|
const _textX = (text, finfo) => {
|
||||||
|
const tw = text.length * finfo.charW * s;
|
||||||
|
if (el.textAlign === 'center') return ax * s - tw / 2;
|
||||||
|
if (el.textAlign === 'right') return ax * s - tw;
|
||||||
|
return ax * s;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (el.type) {
|
||||||
|
case 'text':
|
||||||
|
case 'dynamic_text':
|
||||||
|
case 'clock': {
|
||||||
|
const finfo = FONT_MAP[el.font] || FONT_MAP.press_start;
|
||||||
|
const key = el.binding?.key || '?';
|
||||||
|
const pv = opts.previewValues?.[key];
|
||||||
|
// Substitute {variable} tokens in text using previewValues
|
||||||
|
const _subVars = str => (str || '').replace(/\{(\w+)\}/g, (_, k) => {
|
||||||
|
const v = opts.previewValues?.[k];
|
||||||
|
return v !== undefined && v !== '' ? String(v) : `{${k}}`;
|
||||||
|
});
|
||||||
|
const displayText =
|
||||||
|
el.type === 'text' ? _subVars(el.text || '')
|
||||||
|
: el.type === 'clock' ? (el.format || '%H:%M')
|
||||||
|
: (pv !== undefined && pv !== '' ? String(pv) : `{${key}}`);
|
||||||
|
ctx.font = `${finfo.sizePx * s}px ${finfo.family}`;
|
||||||
|
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||||||
|
ctx.fillText(displayText, _textX(displayText, finfo), (ay + finfo.sizePx) * s);
|
||||||
|
// Second line (text and clock)
|
||||||
|
if (el.type === 'text' && el.text2) {
|
||||||
|
const t2 = _subVars(el.text2);
|
||||||
|
const y2 = ay + finfo.sizePx + (el.lineSpacing ?? 2);
|
||||||
|
ctx.fillText(t2, _textX(t2, finfo), (y2 + finfo.sizePx) * s);
|
||||||
|
}
|
||||||
|
if (el.type === 'clock' && el.format2) {
|
||||||
|
const y2 = ay + finfo.sizePx + (el.lineSpacing ?? 2);
|
||||||
|
ctx.fillText(el.format2, _textX(el.format2, finfo), (y2 + finfo.sizePx) * s);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'countdown': {
|
||||||
|
const finfo = FONT_MAP[el.font] || FONT_MAP.press_start;
|
||||||
|
const t = el.previewText || '--d --h';
|
||||||
|
ctx.font = `${finfo.sizePx * s}px ${finfo.family}`;
|
||||||
|
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||||||
|
ctx.fillText(t, _textX(t, finfo), (ay + finfo.sizePx) * s);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'rectangle': {
|
||||||
|
const rx = ax * s, ry = ay * s;
|
||||||
|
const rw = el.width * s, rh = el.height * s;
|
||||||
|
if (el.hasFill) {
|
||||||
|
ctx.fillStyle = `rgb(${el.fillR},${el.fillG},${el.fillB})`;
|
||||||
|
ctx.fillRect(rx, ry, rw, rh);
|
||||||
|
}
|
||||||
|
if (el.hasOutline) {
|
||||||
|
ctx.strokeStyle = `rgb(${el.outR},${el.outG},${el.outB})`;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeRect(rx, ry, rw, rh);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ellipse': {
|
||||||
|
const cx = (ax + el.width / 2) * s;
|
||||||
|
const cy = (ay + el.height / 2) * s;
|
||||||
|
const rx = (el.width / 2) * s;
|
||||||
|
const ry = (el.height / 2) * s;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
|
||||||
|
if (el.hasFill) {
|
||||||
|
ctx.fillStyle = `rgb(${el.fillR},${el.fillG},${el.fillB})`;
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
if (el.hasOutline) {
|
||||||
|
ctx.strokeStyle = `rgb(${el.outR},${el.outG},${el.outB})`;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'arc': {
|
||||||
|
const cx = (ax + el.width / 2) * s;
|
||||||
|
const cy = (ay + el.height / 2) * s;
|
||||||
|
const rx = (el.width / 2) * s;
|
||||||
|
const ry = (el.height / 2) * s;
|
||||||
|
// PIL: 0°=right, clockwise. Canvas: same with anticlockwise=false
|
||||||
|
const startRad = (el.startAngle ?? 0) * Math.PI / 180;
|
||||||
|
const endRad = (el.endAngle ?? 270) * Math.PI / 180;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(cx, cy, rx, ry, 0, startRad, endRad, false);
|
||||||
|
ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||||||
|
ctx.lineWidth = Math.max(1, el.lineWidth || 2);
|
||||||
|
ctx.stroke();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'pixel': {
|
||||||
|
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||||||
|
ctx.fillRect(ax * s, ay * s, s, s);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'rounded_rectangle': {
|
||||||
|
const rx = ax * s, ry = ay * s;
|
||||||
|
const rw = el.width * s, rh = el.height * s;
|
||||||
|
const rad = Math.min((el.borderRadius ?? 3) * s, rw / 2, rh / 2);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(rx, ry, rw, rh, rad);
|
||||||
|
if (el.hasFill) {
|
||||||
|
ctx.fillStyle = `rgb(${el.fillR},${el.fillG},${el.fillB})`;
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
if (el.hasOutline) {
|
||||||
|
ctx.strokeStyle = `rgb(${el.outR},${el.outG},${el.outB})`;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'line': {
|
||||||
|
ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||||||
|
ctx.lineWidth = Math.max(1, el.lineWidth || 1);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(el.x0 * s, el.y0 * s);
|
||||||
|
ctx.lineTo(el.x1 * s, el.y1 * s);
|
||||||
|
ctx.stroke();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'divider': {
|
||||||
|
const isH = (el.orientation || 'horizontal') === 'horizontal';
|
||||||
|
ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
if (isH) {
|
||||||
|
ctx.moveTo(0, ay * s + 0.5);
|
||||||
|
ctx.lineTo(_canvas.width, ay * s + 0.5);
|
||||||
|
} else {
|
||||||
|
ctx.moveTo(ax * s + 0.5, 0);
|
||||||
|
ctx.lineTo(ax * s + 0.5, _canvas.height);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'pips': {
|
||||||
|
const pipCount = Math.max(1, el.count ?? 5);
|
||||||
|
const pvPips = opts.previewValues?.[el.binding?.key];
|
||||||
|
const filledN = pvPips !== undefined
|
||||||
|
? Math.max(0, Math.min(pipCount, Math.round(parseFloat(pvPips) || 0)))
|
||||||
|
: Math.max(0, Math.min(pipCount, el.filled ?? 3));
|
||||||
|
const ps = Math.max(1, el.pipSize ?? 4);
|
||||||
|
const pg = Math.max(0, el.pipSpacing ?? 2);
|
||||||
|
for (let i = 0; i < pipCount; i++) {
|
||||||
|
const isFilled = i < filledN;
|
||||||
|
if (!isFilled && !el.showEmpty) continue;
|
||||||
|
ctx.fillStyle = isFilled
|
||||||
|
? `rgb(${el.r},${el.g},${el.b})`
|
||||||
|
: `rgb(${el.emptyR ?? 50},${el.emptyG ?? 50},${el.emptyB ?? 50})`;
|
||||||
|
ctx.fillRect((ax + i * (ps + pg)) * s, ay * s, ps * s, ps * s);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'sparkline': {
|
||||||
|
const slW = el.width ?? 40, slH = el.height ?? 12;
|
||||||
|
const count = Math.max(1, el.barCount ?? 8);
|
||||||
|
const spacing = el.barSpacing ?? 1;
|
||||||
|
const barW = Math.max(1, Math.floor((slW - spacing * (count - 1)) / count));
|
||||||
|
const rawVals = (el.previewData || '').split(',')
|
||||||
|
.map(v => parseFloat(v.trim())).filter(n => !isNaN(n));
|
||||||
|
while (rawVals.length < count) rawVals.push(0);
|
||||||
|
const maxV = Math.max(...rawVals.slice(0, count), 0.001);
|
||||||
|
const rx = ax * s, ry = ay * s;
|
||||||
|
if (el.hasBg) {
|
||||||
|
ctx.fillStyle = `rgb(${el.bgR ?? 30},${el.bgG ?? 30},${el.bgB ?? 30})`;
|
||||||
|
ctx.fillRect(rx, ry, slW * s, slH * s);
|
||||||
|
}
|
||||||
|
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const norm = Math.max(0, Math.min(1, rawVals[i] / maxV));
|
||||||
|
const barH = Math.max(1, Math.round(slH * norm));
|
||||||
|
const bx = rx + (barW + spacing) * i * s;
|
||||||
|
const by = ry + (slH - barH) * s;
|
||||||
|
ctx.fillRect(bx, by, barW * s, barH * s);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'gauge': {
|
||||||
|
const gw = (el.width ?? 32), gh = (el.height ?? 32);
|
||||||
|
const cx = (ax + gw / 2) * s, cy = (ay + gh / 2) * s;
|
||||||
|
const rx = (gw / 2) * s, ry = (gh / 2) * s;
|
||||||
|
const lw = Math.max(1, (el.lineWidth ?? 3));
|
||||||
|
const startDeg = el.startAngle ?? 135;
|
||||||
|
const endDeg = el.endAngle ?? 45;
|
||||||
|
// Arc sweep: from startDeg clockwise to endDeg (PIL convention)
|
||||||
|
const totalSweep = ((endDeg - startDeg) + 360) % 360 || 360;
|
||||||
|
const pvGauge = opts.previewValues?.[el.binding?.key];
|
||||||
|
const pct = pvGauge !== undefined
|
||||||
|
? Math.max(0, Math.min(100, parseFloat(pvGauge) || 0)) / 100
|
||||||
|
: Math.max(0, Math.min(100, el.previewPct ?? 65)) / 100;
|
||||||
|
const fillSweep = totalSweep * pct;
|
||||||
|
const toRad = deg => (deg - 90) * Math.PI / 180; // canvas 0=top, PIL 0=right → offset -90
|
||||||
|
|
||||||
|
// Track arc
|
||||||
|
if (el.hasTrack !== false) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(cx, cy, rx - lw / 2, ry - lw / 2, 0, toRad(startDeg), toRad(startDeg + totalSweep), false);
|
||||||
|
ctx.strokeStyle = `rgb(${el.trackR ?? 40},${el.trackG ?? 40},${el.trackB ?? 40})`;
|
||||||
|
ctx.lineWidth = lw * s;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
// Fill arc
|
||||||
|
if (pct > 0) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(cx, cy, rx - lw / 2, ry - lw / 2, 0, toRad(startDeg), toRad(startDeg + fillSweep), false);
|
||||||
|
ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||||||
|
ctx.lineWidth = lw * s;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
// Centre label
|
||||||
|
if (el.showLabel) {
|
||||||
|
const gfinfo = FONT_MAP[el.font || 'four_by_six'] || FONT_MAP.four_by_six;
|
||||||
|
const labelText = Math.round(pct * 100) + '%';
|
||||||
|
ctx.font = `${gfinfo.sizePx * s}px ${gfinfo.family}`;
|
||||||
|
ctx.fillStyle = `rgb(${el.labelR ?? 200},${el.labelG ?? 200},${el.labelB ?? 200})`;
|
||||||
|
const ltw = ctx.measureText(labelText).width;
|
||||||
|
ctx.fillText(labelText, cx - ltw / 2, cy + (gfinfo.sizePx * s) / 2);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'marquee': {
|
||||||
|
const finfo = FONT_MAP[el.font] || FONT_MAP.press_start;
|
||||||
|
const text = el.text || 'Scrolling text';
|
||||||
|
const tw = text.length * finfo.charW * s;
|
||||||
|
const gap = (el.gap ?? 16) * s;
|
||||||
|
const totalW = tw + gap;
|
||||||
|
const tick = opts.animTick ?? 0;
|
||||||
|
const speed = (el.scrollSpeed ?? 1) * 2;
|
||||||
|
const scrolled = (tick * speed) % totalW;
|
||||||
|
// left: text enters from right; right: text enters from left
|
||||||
|
const startX = el.direction === 'right'
|
||||||
|
? scrolled - tw
|
||||||
|
: matrixW * s - scrolled;
|
||||||
|
ctx.font = `${finfo.sizePx * s}px ${finfo.family}`;
|
||||||
|
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||||||
|
// Clip to canvas width so text doesn't bleed outside
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.rect(0, ay * s - 1, matrixW * s, (finfo.sizePx + 2) * s);
|
||||||
|
ctx.clip();
|
||||||
|
for (let i = -1; i <= 2; i++) {
|
||||||
|
ctx.fillText(text, startX + i * totalW, (ay + finfo.sizePx) * s);
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'progress_bar': {
|
||||||
|
const bw = el.barWidth ?? 60, bh = el.barHeight ?? 6;
|
||||||
|
const pvPb = opts.previewValues?.[el.binding?.key];
|
||||||
|
const pct = pvPb !== undefined
|
||||||
|
? Math.max(0, Math.min(100, parseFloat(pvPb) || 0)) / 100
|
||||||
|
: Math.max(0, Math.min(100, el.previewPct ?? 65)) / 100;
|
||||||
|
const rx = ax * s, ry = ay * s;
|
||||||
|
if (el.hasBg) {
|
||||||
|
ctx.fillStyle = `rgb(${el.bgR ?? 30},${el.bgG ?? 30},${el.bgB ?? 30})`;
|
||||||
|
ctx.fillRect(rx, ry, bw * s, bh * s);
|
||||||
|
}
|
||||||
|
const fillW = Math.max(0, Math.round(bw * pct));
|
||||||
|
if (fillW > 0) {
|
||||||
|
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||||||
|
ctx.fillRect(rx, ry, fillW * s, bh * s);
|
||||||
|
}
|
||||||
|
if (el.hasOutline) {
|
||||||
|
ctx.strokeStyle = `rgb(${el.outR ?? 100},${el.outG ?? 100},${el.outB ?? 100})`;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeRect(rx, ry, bw * s, bh * s);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (belowBreakpoint) {
|
||||||
|
ctx.globalAlpha = 0.6;
|
||||||
|
const bb = getBoundingBox(el, matrixW, matrixH);
|
||||||
|
ctx.font = `${Math.max(8, s * 2)}px monospace`;
|
||||||
|
ctx.fillStyle = '#facc15';
|
||||||
|
ctx.fillText(`≥${el.minWidth}px`, bb.x * s, (bb.y + 4) * s);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Selection indicator ──────────────────────────────────────────────
|
||||||
|
function _drawSelection(ctx, el, SCALE, matrixW, matrixH) {
|
||||||
|
const bb = getBoundingBox(el, matrixW, matrixH);
|
||||||
|
const PAD = 2, s = SCALE;
|
||||||
|
const rx = bb.x * s - PAD, ry = bb.y * s - PAD;
|
||||||
|
const rw = bb.w * s + PAD * 2, rh = bb.h * s + PAD * 2;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.strokeStyle = '#3b82f6';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.setLineDash([3, 2]);
|
||||||
|
ctx.strokeRect(rx, ry, rw, rh);
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
|
||||||
|
if (el.xAnchor || el.yAnchor) {
|
||||||
|
ctx.font = `${Math.max(7, s)}px sans-serif`;
|
||||||
|
ctx.fillStyle = '#a78bfa';
|
||||||
|
const anchorText = [
|
||||||
|
el.xAnchor ? `x:${el.xAnchor[0]}` : '',
|
||||||
|
el.yAnchor ? `y:${el.yAnchor[0]}` : '',
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
if (anchorText) ctx.fillText(anchorText, rx + 1, ry - 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resize handles: on rect, rounded rect, ellipse
|
||||||
|
if (['rectangle', 'rounded_rectangle', 'ellipse', 'arc', 'gauge', 'sparkline'].includes(el.type)) {
|
||||||
|
const handles = _getRectHandles(el, matrixW, matrixH);
|
||||||
|
const HS = 5;
|
||||||
|
ctx.fillStyle = 'white';
|
||||||
|
ctx.strokeStyle = '#2563eb';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (const pt of Object.values(handles)) {
|
||||||
|
const hx = pt.x * s - HS / 2;
|
||||||
|
const hy = pt.y * s - HS / 2;
|
||||||
|
ctx.fillRect(hx, hy, HS, HS);
|
||||||
|
ctx.strokeRect(hx, hy, HS, HS);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Corner dots for non-rectangle elements
|
||||||
|
ctx.fillStyle = '#3b82f6';
|
||||||
|
const HS = 4;
|
||||||
|
for (const [hx, hy] of [
|
||||||
|
[rx - HS / 2, ry - HS / 2], [rx + rw - HS / 2, ry - HS / 2],
|
||||||
|
[rx - HS / 2, ry + rh - HS / 2], [rx + rw - HS / 2, ry + rh - HS / 2],
|
||||||
|
]) ctx.fillRect(hx, hy, HS, HS);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dimension tooltip while dragging ─────────────────────────────────
|
||||||
|
function drawDragTooltip(ctx, el, SCALE, matrixW, matrixH) {
|
||||||
|
const bb = getBoundingBox(el, matrixW, matrixH);
|
||||||
|
const label = el.type === 'rectangle'
|
||||||
|
? `${el.width}×${el.height}`
|
||||||
|
: `${bb.x},${bb.y}`;
|
||||||
|
const s = SCALE;
|
||||||
|
ctx.save();
|
||||||
|
ctx.font = `${Math.max(9, s * 1.5)}px monospace`;
|
||||||
|
const tw = ctx.measureText(label).width;
|
||||||
|
const tx = bb.x * s, ty = (bb.y - 2) * s;
|
||||||
|
ctx.fillStyle = 'rgba(0,0,0,0.7)';
|
||||||
|
ctx.fillRect(tx - 2, ty - 10, tw + 4, 12);
|
||||||
|
ctx.fillStyle = 'white';
|
||||||
|
ctx.fillText(label, tx, ty);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function init(canvasEl) {
|
||||||
|
_canvas = canvasEl;
|
||||||
|
_ctx = canvasEl.getContext('2d');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setGrid(show) { _showGrid = show; }
|
||||||
|
|
||||||
|
function updateCanvasSize(matrixW, matrixH, SCALE) {
|
||||||
|
if (!_canvas) return;
|
||||||
|
_canvas.width = matrixW * SCALE;
|
||||||
|
_canvas.height = matrixH * SCALE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(elements, selectedId, matrixW, matrixH, SCALE, opts = {}) {
|
||||||
|
if (!_ctx) return;
|
||||||
|
const cW = matrixW * SCALE, cH = matrixH * SCALE;
|
||||||
|
|
||||||
|
const bg = opts.bgColor;
|
||||||
|
_ctx.fillStyle = bg ? `rgb(${bg.r},${bg.g},${bg.b})` : '#000';
|
||||||
|
_ctx.fillRect(0, 0, cW, cH);
|
||||||
|
|
||||||
|
if (_showGrid) {
|
||||||
|
_ctx.strokeStyle = 'rgba(255,255,255,0.07)';
|
||||||
|
_ctx.lineWidth = 0.5;
|
||||||
|
for (let x = SCALE; x < cW; x += SCALE) {
|
||||||
|
_ctx.beginPath(); _ctx.moveTo(x, 0); _ctx.lineTo(x, cH); _ctx.stroke();
|
||||||
|
}
|
||||||
|
for (let y = SCALE; y < cH; y += SCALE) {
|
||||||
|
_ctx.beginPath(); _ctx.moveTo(0, y); _ctx.lineTo(cW, y); _ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const el of elements) _drawElement(_ctx, el, SCALE, matrixW, matrixH, opts);
|
||||||
|
|
||||||
|
if (opts.showRuler) {
|
||||||
|
_ctx.save();
|
||||||
|
_ctx.fillStyle = 'rgba(255,255,255,0.08)';
|
||||||
|
_ctx.fillRect(0, 0, cW, SCALE); // top strip
|
||||||
|
_ctx.fillRect(0, 0, SCALE, cH); // left strip
|
||||||
|
_ctx.strokeStyle = 'rgba(255,255,255,0.5)';
|
||||||
|
_ctx.fillStyle = 'rgba(255,255,255,0.6)';
|
||||||
|
_ctx.font = `${Math.max(5, SCALE - 1)}px monospace`;
|
||||||
|
const step = SCALE >= 4 ? 8 : 16;
|
||||||
|
for (let px = 0; px <= matrixW; px += step) {
|
||||||
|
const cx = px * SCALE;
|
||||||
|
const major = px % 32 === 0;
|
||||||
|
_ctx.lineWidth = 0.5;
|
||||||
|
_ctx.beginPath(); _ctx.moveTo(cx, 0); _ctx.lineTo(cx, major ? SCALE : SCALE * 0.5); _ctx.stroke();
|
||||||
|
if (major && px > 0 && px < matrixW - 4) _ctx.fillText(String(px), cx + 1, SCALE - 1);
|
||||||
|
}
|
||||||
|
for (let py = 0; py <= matrixH; py += step) {
|
||||||
|
const cy = py * SCALE;
|
||||||
|
const major = py % 32 === 0;
|
||||||
|
_ctx.beginPath(); _ctx.moveTo(0, cy); _ctx.lineTo(major ? SCALE : SCALE * 0.5, cy); _ctx.stroke();
|
||||||
|
if (major && py > 0 && py < matrixH - 4) _ctx.fillText(String(py), 1, cy + SCALE - 1);
|
||||||
|
}
|
||||||
|
_ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.showGuides) {
|
||||||
|
_ctx.save();
|
||||||
|
_ctx.strokeStyle = 'rgba(255,60,60,0.45)';
|
||||||
|
_ctx.lineWidth = 1;
|
||||||
|
_ctx.setLineDash([4, 3]);
|
||||||
|
const mx = Math.floor(cW / 2) + 0.5;
|
||||||
|
const my = Math.floor(cH / 2) + 0.5;
|
||||||
|
_ctx.beginPath(); _ctx.moveTo(mx, 0); _ctx.lineTo(mx, cH); _ctx.stroke();
|
||||||
|
_ctx.beginPath(); _ctx.moveTo(0, my); _ctx.lineTo(cW, my); _ctx.stroke();
|
||||||
|
_ctx.setLineDash([]);
|
||||||
|
_ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
const sel = selectedId != null ? elements.find(e => e.id === selectedId) : null;
|
||||||
|
if (sel) {
|
||||||
|
_drawSelection(_ctx, sel, SCALE, matrixW, matrixH);
|
||||||
|
if (opts.showTooltip) drawDragTooltip(_ctx, sel, SCALE, matrixW, matrixH);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
init, render, setGrid, updateCanvasSize,
|
||||||
|
hitTest, getBoundingBox, computeActualPos, resolveAnchor,
|
||||||
|
getResizeHandle, getCursorForHandle,
|
||||||
|
ELEMENT_DEFAULTS, FONT_MAP, DISPLAY_PRESETS,
|
||||||
|
};
|
||||||
|
})();
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,298 @@
|
|||||||
|
"""
|
||||||
|
{{ plugin_name }} — LEDMatrix Plugin
|
||||||
|
Generated by LEDMatrix Plugin Composer on {{ generated_date }}
|
||||||
|
|
||||||
|
Extension points:
|
||||||
|
update() → add HTTP/MQTT data-fetching logic here
|
||||||
|
_get_display_values() → map fetched data to display strings
|
||||||
|
display() → add new elements or adapt layout per display size
|
||||||
|
"""
|
||||||
|
from src.plugin_system.base_plugin import BasePlugin
|
||||||
|
{% if has_clock or has_countdown %}
|
||||||
|
from datetime import datetime
|
||||||
|
{% endif %}
|
||||||
|
{% if has_blink %}
|
||||||
|
import time
|
||||||
|
{% endif %}
|
||||||
|
{% if has_text_template %}
|
||||||
|
from collections import defaultdict
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
class {{ class_name }}(BasePlugin):
|
||||||
|
|
||||||
|
def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager):
|
||||||
|
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
||||||
|
{% for var in config_vars %}
|
||||||
|
self.{{ var.key }} = config.get({{ var.key | tojson }}, {{ var.default | tojson }})
|
||||||
|
{% endfor %}
|
||||||
|
# Live data cache — populated by update(); always {} in static layouts
|
||||||
|
self._data = {}
|
||||||
|
|
||||||
|
def update(self):
|
||||||
|
"""Fetch and refresh display data.
|
||||||
|
|
||||||
|
For dynamic plugins: fetch from APIs/MQTT here and store in self._data.
|
||||||
|
_get_display_values() will read self._data to produce display strings.
|
||||||
|
"""
|
||||||
|
# --- Data sources (add fetch logic here for dynamic plugins) ---
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _get_display_values(self):
|
||||||
|
"""Map config variables and live data to display-ready strings.
|
||||||
|
|
||||||
|
This is the single extension point for v2 data sources:
|
||||||
|
add self._data lookups here once update() populates them.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
{% for var in config_vars %}
|
||||||
|
{{ var.key | tojson }}: str(self.{{ var.key }}),
|
||||||
|
{% endfor %}
|
||||||
|
}
|
||||||
|
|
||||||
|
def display(self, force_clear=False):
|
||||||
|
try:
|
||||||
|
{% if has_text_template %}
|
||||||
|
values = defaultdict(str, self._get_display_values())
|
||||||
|
{% else %}
|
||||||
|
values = self._get_display_values()
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
if force_clear:
|
||||||
|
self.display_manager.clear()
|
||||||
|
|
||||||
|
width = self.display_manager.width
|
||||||
|
height = self.display_manager.height
|
||||||
|
{% if bg_color %}
|
||||||
|
|
||||||
|
self.display_manager.draw.rectangle([0, 0, width, height], fill={{ bg_color }})
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
# ── Elements (rendered bottom to top) ──────────────────────────
|
||||||
|
{% for el in elements %}
|
||||||
|
{% set p = " " if el.min_width > 0 else " " %}
|
||||||
|
{% set pi = (p + " ") if el.blink else p %}
|
||||||
|
{% if el.min_width > 0 %}
|
||||||
|
if width >= {{ el.min_width }}: # breakpoint: {{ el.min_width }}px+ displays only
|
||||||
|
{% endif %}
|
||||||
|
{% if el.blink %}
|
||||||
|
{{ p }}if int(time.time() * 2) % 2:
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if el.type == 'text' %}
|
||||||
|
{{ pi }}self.display_manager.draw_text(
|
||||||
|
{% if el.text_is_template %}
|
||||||
|
{{ pi }} {{ el.text | tojson }}.format_map(values),
|
||||||
|
{% else %}
|
||||||
|
{{ pi }} {{ el.text | tojson }},
|
||||||
|
{% endif %}
|
||||||
|
{{ pi }} x={{ el.x_expr }}, y={{ el.y_expr }},
|
||||||
|
{{ pi }} color={{ el.rgb_tuple }},
|
||||||
|
{{ pi }} font=self.display_manager.{{ el.font_attr }},
|
||||||
|
{{ pi }})
|
||||||
|
{% if el.text2 %}
|
||||||
|
{{ pi }}self.display_manager.draw_text(
|
||||||
|
{% if el.text_is_template %}
|
||||||
|
{{ pi }} {{ el.text2 | tojson }}.format_map(values),
|
||||||
|
{% else %}
|
||||||
|
{{ pi }} {{ el.text2 | tojson }},
|
||||||
|
{% endif %}
|
||||||
|
{{ pi }} x={{ el.x2_expr }}, y={{ el.y2_expr }},
|
||||||
|
{{ pi }} color={{ el.rgb_tuple }},
|
||||||
|
{{ pi }} font=self.display_manager.{{ el.font_attr }},
|
||||||
|
{{ pi }})
|
||||||
|
{% endif %}
|
||||||
|
{% elif el.type == 'dynamic_text' %}
|
||||||
|
{% if el.binding_source == 'config' %}
|
||||||
|
{{ pi }}self.display_manager.draw_text(
|
||||||
|
{{ pi }} values.get({{ el.binding_key | tojson }}, ''),
|
||||||
|
{{ pi }} x={{ el.x_expr }}, y={{ el.y_expr }},
|
||||||
|
{{ pi }} color={{ el.rgb_tuple }},
|
||||||
|
{{ pi }} font=self.display_manager.{{ el.font_attr }},
|
||||||
|
{{ pi }})
|
||||||
|
{% endif %}
|
||||||
|
{% elif el.type == 'clock' %}
|
||||||
|
{{ pi }}self.display_manager.draw_text(
|
||||||
|
{{ pi }} datetime.now().strftime({{ el.format | tojson }}),
|
||||||
|
{{ pi }} x={{ el.x_expr }}, y={{ el.y_expr }},
|
||||||
|
{{ pi }} color={{ el.rgb_tuple }},
|
||||||
|
{{ pi }} font=self.display_manager.{{ el.font_attr }},
|
||||||
|
{{ pi }})
|
||||||
|
{% if el.format2 %}
|
||||||
|
{{ pi }}self.display_manager.draw_text(
|
||||||
|
{{ pi }} datetime.now().strftime({{ el.format2 | tojson }}),
|
||||||
|
{{ pi }} x={{ el.x2_expr }}, y={{ el.y2_expr }},
|
||||||
|
{{ pi }} color={{ el.rgb_tuple }},
|
||||||
|
{{ pi }} font=self.display_manager.{{ el.font_attr }},
|
||||||
|
{{ pi }})
|
||||||
|
{% endif %}
|
||||||
|
{% elif el.type == 'countdown' %}
|
||||||
|
{{ pi }}_cd_target = float(values.get({{ el.binding_key | tojson }}, 0) or 0)
|
||||||
|
{{ pi }}_cd_secs = max(0.0, _cd_target - datetime.now().timestamp())
|
||||||
|
{% if el.countdown_format == 'dhms' %}
|
||||||
|
{{ pi }}_cd_d, _cd_rem = divmod(int(_cd_secs), 86400)
|
||||||
|
{{ pi }}_cd_h, _cd_rem = divmod(_cd_rem, 3600)
|
||||||
|
{{ pi }}_cd_m, _cd_s = divmod(_cd_rem, 60)
|
||||||
|
{{ pi }}_cd_str = f'{_cd_d}d {_cd_h:02d}:{_cd_m:02d}:{_cd_s:02d}'
|
||||||
|
{% elif el.countdown_format == 'hms' %}
|
||||||
|
{{ pi }}_cd_h, _cd_rem = divmod(int(_cd_secs), 3600)
|
||||||
|
{{ pi }}_cd_m, _cd_s = divmod(_cd_rem, 60)
|
||||||
|
{{ pi }}_cd_str = f'{_cd_h}h {_cd_m:02d}:{_cd_s:02d}'
|
||||||
|
{% elif el.countdown_format == 'dhm' %}
|
||||||
|
{{ pi }}_cd_d, _cd_rem = divmod(int(_cd_secs), 86400)
|
||||||
|
{{ pi }}_cd_h, _cd_m = divmod(_cd_rem // 60, 60)
|
||||||
|
{{ pi }}_cd_str = f'{_cd_d}d {_cd_h:02d}h {_cd_m:02d}m'
|
||||||
|
{% else %}
|
||||||
|
{{ pi }}_cd_d, _cd_rem = divmod(int(_cd_secs), 86400)
|
||||||
|
{{ pi }}_cd_h = _cd_rem // 3600
|
||||||
|
{{ pi }}_cd_str = f'{_cd_d}d {_cd_h}h'
|
||||||
|
{% endif %}
|
||||||
|
{{ pi }}self.display_manager.draw_text(
|
||||||
|
{{ pi }} _cd_str,
|
||||||
|
{{ pi }} x={{ el.x_expr }}, y={{ el.y_expr }},
|
||||||
|
{{ pi }} color={{ el.rgb_tuple }},
|
||||||
|
{{ pi }} font=self.display_manager.{{ el.font_attr }},
|
||||||
|
{{ pi }})
|
||||||
|
{% elif el.type == 'rectangle' %}
|
||||||
|
{{ pi }}self.display_manager.draw.rectangle(
|
||||||
|
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}],
|
||||||
|
{{ pi }} fill={{ el.fill_tuple }},
|
||||||
|
{{ pi }} outline={{ el.outline_tuple }},
|
||||||
|
{{ pi }})
|
||||||
|
{% elif el.type == 'arc' %}
|
||||||
|
{{ pi }}self.display_manager.draw.arc(
|
||||||
|
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}],
|
||||||
|
{{ pi }} start={{ el.start_angle }}, end={{ el.end_angle }},
|
||||||
|
{{ pi }} fill={{ el.rgb_tuple }},
|
||||||
|
{{ pi }} width={{ el.line_width }},
|
||||||
|
{{ pi }})
|
||||||
|
{% elif el.type == 'ellipse' %}
|
||||||
|
{{ pi }}self.display_manager.draw.ellipse(
|
||||||
|
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}],
|
||||||
|
{{ pi }} fill={{ el.fill_tuple }},
|
||||||
|
{{ pi }} outline={{ el.outline_tuple }},
|
||||||
|
{{ pi }})
|
||||||
|
{% elif el.type == 'pixel' %}
|
||||||
|
{{ pi }}self.display_manager.draw.point(
|
||||||
|
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}],
|
||||||
|
{{ pi }} fill={{ el.rgb_tuple }},
|
||||||
|
{{ pi }})
|
||||||
|
{% elif el.type == 'rounded_rectangle' %}
|
||||||
|
{{ pi }}self.display_manager.draw.rounded_rectangle(
|
||||||
|
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}],
|
||||||
|
{{ pi }} radius={{ el.border_radius }},
|
||||||
|
{{ pi }} fill={{ el.fill_tuple }},
|
||||||
|
{{ pi }} outline={{ el.outline_tuple }},
|
||||||
|
{{ pi }})
|
||||||
|
{% elif el.type in ('line', 'divider') %}
|
||||||
|
{{ pi }}self.display_manager.draw.line(
|
||||||
|
{{ pi }} [{{ el.x0_expr }}, {{ el.y0_expr }}, {{ el.x1_expr }}, {{ el.y1_expr }}],
|
||||||
|
{{ pi }} fill={{ el.rgb_tuple }},
|
||||||
|
{{ pi }} width={{ el.line_width }},
|
||||||
|
{{ pi }})
|
||||||
|
{% elif el.type == 'pips' %}
|
||||||
|
{{ pi }}_pip_filled = max(0, min({{ el.pip_count }}, int(float(values.get({{ el.binding_key | tojson }}, 0) or 0))))
|
||||||
|
{{ pi }}for _pip_i in range({{ el.pip_count }}):
|
||||||
|
{{ pi }} _pip_x = ({{ el.x_expr }}) + _pip_i * ({{ el.pip_size }} + {{ el.pip_spacing }})
|
||||||
|
{{ pi }} _pip_color = {{ el.fill_tuple }} if _pip_i < _pip_filled else {{ el.empty_tuple }}
|
||||||
|
{% if not el.show_empty %}
|
||||||
|
{{ pi }} if _pip_i >= _pip_filled:
|
||||||
|
{{ pi }} continue
|
||||||
|
{% endif %}
|
||||||
|
{{ pi }} self.display_manager.draw.rectangle(
|
||||||
|
{{ pi }} [_pip_x, {{ el.y_expr }}, _pip_x + {{ el.pip_size }} - 1, ({{ el.y_expr }}) + {{ el.pip_size }} - 1],
|
||||||
|
{{ pi }} fill=_pip_color,
|
||||||
|
{{ pi }} )
|
||||||
|
{% elif el.type == 'sparkline' %}
|
||||||
|
{{ pi }}_sl_raw = str(values.get({{ el.binding_key | tojson }}, '') or '')
|
||||||
|
{{ pi }}_sl_vals = [float(v.strip()) for v in _sl_raw.split(',') if v.strip()][:{{ el.bar_count }}]
|
||||||
|
{{ pi }}_sl_vals += [0.0] * max(0, {{ el.bar_count }} - len(_sl_vals))
|
||||||
|
{{ pi }}_sl_max = max(_sl_vals) if any(_sl_vals) else 1.0
|
||||||
|
{{ pi }}_sl_bw = max(1, ({{ el.bar_width_px }} - {{ el.bar_spacing }} * ({{ el.bar_count }} - 1)) // {{ el.bar_count }})
|
||||||
|
{% if el.bg_tuple != 'None' %}
|
||||||
|
{{ pi }}self.display_manager.draw.rectangle(
|
||||||
|
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, ({{ el.x_expr }}) + {{ el.bar_width_px }}, ({{ el.y_expr }}) + {{ el.bar_height_px }}],
|
||||||
|
{{ pi }} fill={{ el.bg_tuple }},
|
||||||
|
{{ pi }})
|
||||||
|
{% endif %}
|
||||||
|
{{ pi }}for _sl_i, _sl_v in enumerate(_sl_vals):
|
||||||
|
{{ pi }} _sl_norm = max(0.0, min(1.0, _sl_v / (_sl_max or 1)))
|
||||||
|
{{ pi }} _sl_bh = max(1, round({{ el.bar_height_px }} * _sl_norm))
|
||||||
|
{{ pi }} _sl_bx = ({{ el.x_expr }}) + (_sl_bw + {{ el.bar_spacing }}) * _sl_i
|
||||||
|
{{ pi }} _sl_by = ({{ el.y_expr }}) + {{ el.bar_height_px }} - _sl_bh
|
||||||
|
{{ pi }} self.display_manager.draw.rectangle(
|
||||||
|
{{ pi }} [_sl_bx, _sl_by, _sl_bx + _sl_bw - 1, _sl_by + _sl_bh - 1],
|
||||||
|
{{ pi }} fill={{ el.fill_tuple }},
|
||||||
|
{{ pi }} )
|
||||||
|
{% elif el.type == 'gauge' %}
|
||||||
|
{{ pi }}_gv = max(0.0, min(100.0, float(values.get({{ el.binding_key | tojson }}, 0) or 0)))
|
||||||
|
{{ pi }}_g_total = (({{ el.end_angle }} - {{ el.start_angle }}) % 360) or 360
|
||||||
|
{{ pi }}_g_sweep = _g_total * _gv / 100.0
|
||||||
|
{% if el.track_tuple != 'None' %}
|
||||||
|
{{ pi }}self.display_manager.draw.arc(
|
||||||
|
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}],
|
||||||
|
{{ pi }} start={{ el.start_angle }}, end={{ el.start_angle }} + _g_total,
|
||||||
|
{{ pi }} fill={{ el.track_tuple }},
|
||||||
|
{{ pi }} width={{ el.line_width }},
|
||||||
|
{{ pi }})
|
||||||
|
{% endif %}
|
||||||
|
{{ pi }}if _g_sweep > 0:
|
||||||
|
{{ pi }} self.display_manager.draw.arc(
|
||||||
|
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}],
|
||||||
|
{{ pi }} start={{ el.start_angle }}, end={{ el.start_angle }} + _g_sweep,
|
||||||
|
{{ pi }} fill={{ el.rgb_tuple }},
|
||||||
|
{{ pi }} width={{ el.line_width }},
|
||||||
|
{{ pi }} )
|
||||||
|
{% if el.show_label %}
|
||||||
|
{{ pi }}_g_cx = ({{ el.x_expr }}) + ({{ el.x2_expr }} - ({{ el.x_expr }})) // 2
|
||||||
|
{{ pi }}_g_cy = ({{ el.y_expr }}) + ({{ el.y2_expr }} - ({{ el.y_expr }})) // 2
|
||||||
|
{{ pi }}self.display_manager.draw_text(
|
||||||
|
{{ pi }} f'{int(_gv)}%',
|
||||||
|
{{ pi }} x=_g_cx, y=_g_cy,
|
||||||
|
{{ pi }} color={{ el.label_tuple }},
|
||||||
|
{{ pi }} font=self.display_manager.{{ el.font_attr }},
|
||||||
|
{{ pi }})
|
||||||
|
{% endif %}
|
||||||
|
{% elif el.type == 'marquee' %}
|
||||||
|
{{ pi }}_{{ el.data_key }}_text = {{ el.text | tojson }}
|
||||||
|
{{ pi }}_{{ el.data_key }}_tw = len(_{{ el.data_key }}_text) * {{ el.char_w }}
|
||||||
|
{{ pi }}_{{ el.data_key }}_x = int(self._data.get({{ el.data_key | tojson }}, width))
|
||||||
|
{% if el.direction == 'right' %}
|
||||||
|
{{ pi }}_{{ el.data_key }}_x += {{ el.scroll_speed }}
|
||||||
|
{{ pi }}if _{{ el.data_key }}_x > width:
|
||||||
|
{{ pi }} _{{ el.data_key }}_x = -(_{{ el.data_key }}_tw + {{ el.gap }})
|
||||||
|
{% else %}
|
||||||
|
{{ pi }}_{{ el.data_key }}_x -= {{ el.scroll_speed }}
|
||||||
|
{{ pi }}if _{{ el.data_key }}_x < -(_{{ el.data_key }}_tw + {{ el.gap }}):
|
||||||
|
{{ pi }} _{{ el.data_key }}_x = width
|
||||||
|
{% endif %}
|
||||||
|
{{ pi }}self._data[{{ el.data_key | tojson }}] = _{{ el.data_key }}_x
|
||||||
|
{{ pi }}self.display_manager.draw_text(
|
||||||
|
{{ pi }} _{{ el.data_key }}_text,
|
||||||
|
{{ pi }} x=_{{ el.data_key }}_x, y={{ el.y_expr }},
|
||||||
|
{{ pi }} color={{ el.rgb_tuple }},
|
||||||
|
{{ pi }} font=self.display_manager.{{ el.font_attr }},
|
||||||
|
{{ pi }})
|
||||||
|
{% elif el.type == 'progress_bar' %}
|
||||||
|
{{ pi }}_pb_x = {{ el.x_expr }}
|
||||||
|
{{ pi }}_pb_y = {{ el.y_expr }}
|
||||||
|
{{ pi }}_pb_pct = max(0.0, min(100.0, float(values.get({{ el.binding_key | tojson }}, 0) or 0))) / 100.0
|
||||||
|
{{ pi }}_pb_fill_w = int({{ el.bar_width }} * _pb_pct)
|
||||||
|
{{ pi }}self.display_manager.draw.rectangle(
|
||||||
|
{{ pi }} [_pb_x, _pb_y, _pb_x + {{ el.bar_width }}, _pb_y + {{ el.bar_height }}],
|
||||||
|
{{ pi }} fill={{ el.bg_tuple }},
|
||||||
|
{{ pi }} outline={{ el.outline_tuple }},
|
||||||
|
{{ pi }})
|
||||||
|
{{ pi }}if _pb_fill_w > 0:
|
||||||
|
{{ pi }} self.display_manager.draw.rectangle(
|
||||||
|
{{ pi }} [_pb_x, _pb_y, _pb_x + _pb_fill_w, _pb_y + {{ el.bar_height }}],
|
||||||
|
{{ pi }} fill={{ el.fill_tuple }},
|
||||||
|
{{ pi }} )
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
# ── End elements ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
self.display_manager.update_display()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error('Display error: %s', e, exc_info=True)
|
||||||
Reference in New Issue
Block a user