mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 16:58:06 +00:00
feat(web): add Plugin Composer -- visual drag-and-drop plugin builder
Web UI (/composer/) for building a working LEDMatrix plugin without writing Python: drop elements (text, time, date, countdown, scrolling text, bar/waveform, groups, custom config variables) onto a canvas matching the real panel's pixel grid, configure them with live preview, then generate a real plugin (manager.py + manifest.json + config_schema.json) from manager.py.j2 -- downloadable as a ZIP or installed directly. NOTE: composer_bp is not yet registered in web_interface/app.py, so this blueprint is currently inert. Split out of the original chore/dead-code- removal commit, which had accidentally bundled this in alongside unrelated dead-code deletions; app.py registration was not part of that commit either and still needs to be added before this is reachable. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
14a59c863c
commit
e319540c6e
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