mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 14:55:08 +00:00
composer_bp defined the whole Plugin Composer feature -- the /composer/ page and all its API routes -- but web_interface/app.py never imported or registered it, so every composer URL 404'd in the running app (every composer test builds its own minimal Flask app and registers the blueprint directly, which is why this went unnoticed). Wire it up the same way pages_v3/api_v3 are: import, set config_manager/plugin_manager/ plugins_dir/project_root, register_blueprint(url_prefix='/composer') -- matching the prefix composer.html and composer-app.js already hardcode. Also closes the other still-open half of a CodeRabbit finding: manager.py.j2 already guards element types the template has no branch for, but a dynamic_text element with binding.source other than 'config' hit the same empty-if-block bug one level deeper (its own inner if produced nothing). Added the same pass fallback. Verified both against current code before fixing; the other 8 findings from that review were already fixed in earlier commits on this branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
303 lines
12 KiB
Django/Jinja
303 lines
12 KiB
Django/Jinja
"""
|
|
{{ 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 }})
|
|
{% else %}
|
|
{{ pi }}pass # dynamic_text binding_source "{{ el.binding_source }}" draws nothing
|
|
{% 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 }} )
|
|
{% else %}
|
|
{{ pi }}pass # element type "{{ el.type }}" draws nothing
|
|
{% endif %}
|
|
{% endfor %}
|
|
# ── End elements ───────────────────────────────────────────────
|
|
|
|
self.display_manager.update_display()
|
|
|
|
except Exception as e:
|
|
self.logger.error('Display error: %s', e, exc_info=True)
|