chore: mark skins unsupported, fix stale docs and preview size, prepare 3.4.0

Skins: no current scoreboard plugin builds on src.base_classes, so the only
skin hook (SportsCore._render_game) never runs. The plugin schema endpoint no
longer injects the Visual Skin dropdown, the store hides and refuses
"type": "skin" registry entries, and GET /api/v3/skins reports
supported: false with a message. Stored skin config still loads and saves.
src/skin_system/ and its tests are unchanged apart from the support flag.

Docs: check_plugin.py/render_plugin.py examples use --plugin; document
BasePlugin.get_update_interval() and its interaction with the manifest
update_interval; CLAUDE.md drops the stale template line number and
recommends display_manager.width/height.

Preview size: new src/display_geometry.py holds the size computation and
defaults DisplayManager uses (double-sided applied, chain_length default 2).
The web preview, /display/current, Starlark magnify default, sync handshake
and two dev scripts use it.

Release: __version__ 3.4.0, CHANGELOG 3.4.0 section plus a 3.3.0 tag note.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-14 15:34:04 -04:00
co-authored by Claude Opus 5
parent 6d1cbfb70b
commit c11fff9760
25 changed files with 527 additions and 164 deletions
+5 -9
View File
@@ -748,16 +748,12 @@ def display_preview_generator():
except OSError:
pass # display side treats a missing marker as "no viewer"
# Get display dimensions from config
# Get display dimensions from config: the logical size DisplayManager
# renders at, so double-sided setups preview one screen
try:
main_config = config_manager.load_config()
cols = main_config.get('display', {}).get('hardware', {}).get('cols', 64)
chain_length = main_config.get('display', {}).get('hardware', {}).get('chain_length', 2)
rows = main_config.get('display', {}).get('hardware', {}).get('rows', 32)
parallel = main_config.get('display', {}).get('hardware', {}).get('parallel', 1)
width = cols * chain_length
height = rows * parallel
except (KeyError, TypeError, ValueError, ConfigError):
from src.display_geometry import logical_size
width, height = logical_size(config_manager.load_config())
except (KeyError, TypeError, ValueError, AttributeError, ConfigError):
width = 128
height = 64
+8 -5
View File
@@ -1693,11 +1693,14 @@ def _standalone_render_starlark_app(app_id: str) -> Tuple[bool, int, Optional[st
magnify = plugin_config.get('magnify')
if magnify is None:
hw = full_config.get('display', {}).get('hardware', {})
cols = hw.get('cols', 64)
chain = hw.get('chain_length', 1)
rows = hw.get('rows', 32)
magnify = max(1, min(8, int(min((cols * chain) / 64, rows / 32))))
# The size DisplayManager renders at (shared defaults, double-sided
# applied), so the Pixlet render matches the screen it lands on
from src.display_geometry import logical_size
try:
width, height = logical_size(full_config)
except (TypeError, ValueError):
width, height = 64, 32
magnify = max(1, min(8, int(min(width / 64, height / 32))))
else:
try:
magnify = max(1, min(8, int(magnify)))
+4 -9
View File
@@ -25,17 +25,12 @@ def get_display_current():
snapshot_path = "/tmp/led_matrix_preview.png"
# Get display dimensions from config
# Get display dimensions from config: the logical size DisplayManager
# renders at, so double-sided setups preview one screen
try:
if api_v3.config_manager:
main_config = api_v3.config_manager.load_config()
hardware_config = main_config.get('display', {}).get('hardware', {})
cols = hardware_config.get('cols', 64)
chain_length = hardware_config.get('chain_length', 2)
rows = hardware_config.get('rows', 32)
parallel = hardware_config.get('parallel', 1)
width = cols * chain_length
height = rows * parallel
from src.display_geometry import logical_size
width, height = logical_size(api_v3.config_manager.load_config())
else:
width = 128
height = 64
+11 -2
View File
@@ -154,9 +154,15 @@ def list_skins():
"""List installed visual skins (docs/SKIN_SYSTEM.md).
Optional ?plugin_id=... filters to skins matching that plugin.
The response carries ``supported: false`` and a ``message``: the current
scoreboard plugins don't render skins, so a client must not present
these as selectable.
"""
try:
from src.skin_system import skin_runtime
from src.skin_system import (
SKINS_RENDER_SUPPORTED, SKINS_UNSUPPORTED_MESSAGE, skin_runtime,
)
plugin_id = request.args.get('plugin_id')
if plugin_id:
@@ -181,7 +187,10 @@ def list_skins():
'modes': manifest.get('modes', []),
'has_preview': bool(preview and (skin_dir / preview).is_file()),
})
return jsonify({'status': 'success', 'data': {'skins': payload}})
data = {'skins': payload, 'supported': SKINS_RENDER_SUPPORTED}
if not SKINS_RENDER_SUPPORTED:
data['message'] = SKINS_UNSUPPORTED_MESSAGE
return jsonify({'status': 'success', 'data': data})
except Exception as e:
logger.error('Error in list_skins', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
+24 -13
View File
@@ -1256,6 +1256,16 @@ def install_plugin():
plugin_id = data['plugin_id']
branch = data.get('branch') # Optional branch parameter
# A registry skin would install but never render with the current
# scoreboards; refuse it with the reason rather than a generic failure
try:
registry_entry = api_v3.plugin_store_manager.get_registry_info(plugin_id)
except Exception:
registry_entry = None
if isinstance(registry_entry, dict) and (registry_entry.get('type') or 'plugin') == 'skin':
from src.skin_system import SKINS_UNSUPPORTED_MESSAGE
return jsonify({'status': 'error', 'message': SKINS_UNSUPPORTED_MESSAGE}), 400
# Install the plugin
# Log the plugins directory being used for debugging
plugins_dir = api_v3.plugin_store_manager.plugins_dir
@@ -1456,9 +1466,13 @@ def get_registry_from_url():
registry = api_v3.plugin_store_manager.fetch_registry_from_url(repo_url)
if registry:
# Skins aren't offered: current scoreboards don't render them
return jsonify({
'status': 'success',
'plugins': registry.get('plugins', []),
'plugins': [
p for p in registry.get('plugins', [])
if not (isinstance(p, dict) and (p.get('type') or 'plugin') == 'skin')
],
'registry_url': repo_url
})
else:
@@ -1573,6 +1587,10 @@ def list_plugin_store():
# Format plugins for the web interface
formatted_plugins = []
for plugin in plugins:
# Registry skins install but never render with the current
# scoreboards, so the store doesn't offer them
if (plugin.get('type') or 'plugin') == 'skin':
continue
formatted_plugins.append({
'id': plugin.get('id'),
'name': plugin.get('name'),
@@ -2649,18 +2667,11 @@ def get_plugin_schema():
schema = schema_mgr.load_schema(plugin_id, use_cache=True)
if schema:
# Offer installed visual skins as a dropdown (returns a copy;
# the cached schema and validation are never enum-restricted)
try:
current_skin = None
if api_v3.config_manager:
config = api_v3.config_manager.load_config()
current_skin = config.get(plugin_id, {}).get('skin')
injected = schema_mgr.inject_skin_selector(schema, plugin_id, current_skin)
if isinstance(injected, dict):
schema = injected
except Exception:
logger.debug('Skin selector injection failed for %s', plugin_id, exc_info=True)
# No "Visual Skin" dropdown: the current scoreboard plugins don't
# render skins (src.skin_system.SKINS_UNSUPPORTED_MESSAGE), so
# schema_mgr.inject_skin_selector is deliberately not called. A
# stored "skin" value is unaffected: validation still allows it
# and a form save deep-merges over the stored section, keeping it.
return jsonify({'status': 'success', 'data': {'schema': schema}})
# Return a simple default schema if file not found