Chaotic mega-merge into main. THINGS WILL PROBABLY BE BROKEN


* chore: Update soccer-scoreboard submodule to merged commit

- Update submodule reference to include manifest.json v2 registry format
- Version updated to 1.0.1

* refactor: Remove test_mode and logo_dir config reading from base SportsCore

- Remove test_mode initialization and usage
- Remove logo_dir reading from mode_config
- Use LogoDownloader defaults directly for logo directories

* chore: Update plugin submodules after removing global properties

- Update basketball-scoreboard submodule (removed global test_mode, live_priority, dynamic_duration, logo_dir)
- Update soccer-scoreboard submodule (removed global test_mode, live_priority, dynamic_duration, logo_dir)

* feat(calendar): Add credentials.json file upload via web interface

- Add API endpoint /api/v3/plugins/calendar/upload-credentials for file upload
- Validate JSON format and Google OAuth structure
- Save file to plugin directory with secure permissions (0o600)
- Backup existing credentials.json before overwriting
- Add file upload widget support for string fields in config forms
- Add frontend handler handleCredentialsUpload() for single file uploads
- Update .gitignore to allow calendar submodule
- Update calendar submodule reference

* fix(web): Improve spacing for nested configuration sections

- Add dynamic margin based on nesting depth (mb-6 for deeply nested sections)
- Increase padding in nested content areas (py-3 to py-4)
- Add extra spacing after nested sections to prevent overlap
- Enhance CSS spacing for nested sections (1.5rem for nested, 2rem for deeply nested)
- Add padding-bottom to expanded nested content to prevent cutoff
- Fixes issue where game_limits and other nested settings were hidden under next section header

* chore(plugins): Update sports scoreboard plugins with live update interval fix

- Updated hockey-scoreboard, football-scoreboard, basketball-scoreboard, and soccer-scoreboard submodules
- All plugins now fix the interval selection bug that caused live games to update every 5 minutes instead of 30 seconds
- Ensures all live games update at the configured live_update_interval (30s) for timely score updates

* fix: Initialize test_mode in SportsLive and fix config migration

- Add test_mode initialization in SportsLive.__init__() to prevent AttributeError
- Remove invalid new_secrets parameter from save_config_atomic() call in config migration
- Fixes errors: 'NBALiveManager' object has no attribute 'test_mode'
- Fixes errors: ConfigManager.save_config_atomic() got unexpected keyword argument 'new_secrets'

* chore: Update submodules with test_mode initialization fixes

- Update basketball-scoreboard submodule
- Update soccer-scoreboard submodule

* fix(plugins): Auto-stash local changes before plugin updates

- Automatically stash uncommitted changes before git pull during plugin updates
- Prevents update failures when plugins have local modifications
- Improves error messages for git update failures
- Matches behavior of main LEDMatrix update process

* fix(basketball-scoreboard): Update submodule with timeout fix

- Updated basketball-scoreboard plugin to fix update() timeout issue
- Plugin now uses fire-and-forget odds fetching for upcoming games
- Prevents 30-second timeout when processing many upcoming games

Also fixed permission issue on devpi:
- Changed /var/cache/ledmatrix/display_on_demand_state.json permissions
  from 600 to 660 to allow web service (devpi user) to read the file

* fix(cache): Ensure cache files use 660 permissions for group access

- Updated setup_cache.sh to set file permissions to 660 (not 775)
- Updated first_time_install.sh to properly set cache file permissions
- Modified DiskCache to set 660 permissions when creating cache files
- Ensures display_on_demand_state.json and other cache files are readable
  by web service (devpi user) which is in ledmatrix group

This fixes permission issues where cache files were created with 600
permissions, preventing the web service from reading them. Now files
are created with 660 (rw-rw----) allowing group read access.

* fix(soccer-scoreboard): Update submodule with manifest fix

- Updated soccer-scoreboard plugin submodule
- Added missing entry_point and class_name to manifest.json
- Fixes plugin loading error: 'No class_name in manifest'

Also fixed cache file permissions on devpi server:
- Changed display_on_demand_state.json from 600 to 660 permissions
- Allows web service (devpi user) to read cache files

* fix(display): Remove update_display() calls from clear() to prevent black flash

Previously, display_manager.clear() was calling update_display() twice,
which immediately showed a black screen on the hardware before new
content could be drawn. This caused visible black flashes when switching
between modes, especially when plugins switch from general modes (e.g.,
football_upcoming) to specific sub-modes (e.g., nfl_upcoming).

Now clear() only prepares the buffer without updating the hardware.
Callers can decide when to update the display, allowing smooth transitions
from clear → draw → update_display() without intermediate black flashes.

Places that intentionally show a cleared screen (error cases) already
explicitly call update_display() after clear(), so backward compatibility
is maintained.

* fix(scroll): Prevent wrap-around before cycle completion in dynamic duration

- Check scroll completion BEFORE allowing wrap-around
- Clamp scroll_position when complete to prevent visual loop
- Only wrap-around if cycle is not complete yet
- Fixes issue where stocks plugin showed first stock again at end
- Completion logged only once to avoid spam
- Ensures smooth transition to next mode without visual repeat

* fix(on-demand): Ensure on-demand buttons work and display service runs correctly

- Add early stub functions for on-demand modal to ensure availability when Alpine.js initializes
- Increase on-demand request cache max_age from 5min to 1hr to prevent premature expiration
- Fixes issue where on-demand buttons were not functional due to timing issues
- Ensures display service properly picks up on-demand requests when started

* test: Add comprehensive test coverage (30%+)

- Add 100+ new tests across core components
- Add tests for LayoutManager (27 tests)
- Add tests for PluginLoader (14 tests)
- Add tests for SchemaManager (20 tests)
- Add tests for MemoryCache and DiskCache (24 tests)
- Add tests for TextHelper (9 tests)
- Expand error handling tests (7 new tests)
- Improve coverage from 25.63% to 30.26%
- All 237 tests passing

Test files added:
- test/test_layout_manager.py
- test/test_plugin_loader.py
- test/test_schema_manager.py
- test/test_text_helper.py
- test/test_config_service.py
- test/test_display_controller.py
- test/test_display_manager.py
- test/test_error_handling.py
- test/test_font_manager.py
- test/test_plugin_system.py

Updated:
- pytest.ini: Enable coverage reporting with 30% threshold
- test/conftest.py: Enhanced fixtures for better test isolation
- test/test_cache_manager.py: Expanded cache component tests
- test/test_config_manager.py: Additional config tests

Documentation:
- HOW_TO_RUN_TESTS.md: Guide for running and understanding tests

* test(web): Add comprehensive API endpoint tests

- Add 30 new tests for Flask API endpoints in test/test_web_api.py
- Cover config, system, display, plugins, fonts, and error handling APIs
- Increase test coverage from 30.26% to 30.87%
- All 267 tests passing

Tests cover:
- Config API: GET/POST main config, schedule, secrets
- System API: Status, version, system actions
- Display API: Current display, on-demand start/stop
- Plugins API: Installed plugins, health, config, operations, state
- Fonts API: Catalog, tokens, overrides
- Error handling: Invalid JSON, missing fields, 404s

* test(plugins): Add comprehensive integration tests for all plugins

- Add base test class for plugin integration tests
- Create integration tests for all 6 plugins:
  - basketball-scoreboard (11 tests)
  - calendar (10 tests)
  - clock-simple (11 tests)
  - odds-ticker (9 tests)
  - soccer-scoreboard (11 tests)
  - text-display (12 tests)
- Total: 64 new plugin integration tests
- Increase test coverage from 30.87% to 33.38%
- All 331 tests passing

Tests verify:
- Plugin loading and instantiation
- Required methods (update, display)
- Manifest validation
- Display modes
- Config schema validation
- Graceful handling of missing API credentials

Uses hybrid approach: integration tests in main repo,
plugin-specific unit tests remain in plugin submodules.

* Add mqtt-notifications plugin as submodule

* fix(sports): Respect games_to_show settings for favorite teams

- Fix upcoming games to show N games per team (not just 1)
- Fix recent games to show N games per team (not just 1)
- Add duplicate removal for games involving multiple favorite teams
- Match behavior of basketball-scoreboard plugin
- Affects NFL, NHL, and other sports using base_classes/sports.py

* chore: Remove debug instrumentation logs

- Remove temporary debug logging added during fix verification
- Fix confirmed working by user

* debug: Add instrumentation to debug configuration header visibility issue

* fix: Resolve nested section content sliding under next header

- Remove overflow-hidden from nested-section to allow proper document flow
- Add proper z-index and positioning to prevent overlap
- Add margin-top to nested sections for better spacing
- Remove debug instrumentation that was causing ERR_BLOCKED_BY_CLIENT errors

* fix: Prevent unnecessary plugin tab redraws

- Add check to only update tabs when plugin list actually changes
- Increase debounce timeout to batch rapid changes
- Compare plugin IDs before updating to avoid redundant redraws
- Fix setter to check for actual changes before triggering updates

* fix: Prevent form-groups from sliding out of view when nested sections expand

- Increase margin-bottom on nested-sections for better spacing
- Add clear: both to nested-sections to ensure proper document flow
- Change overflow to visible when expanded to allow natural flow
- Add margin-bottom to expanded content
- Add spacing rules for form-groups that follow nested sections
- Add clear spacer div after nested sections

* fix: Reduce excessive debug logging in generateConfigForm

- Only log once per plugin instead of on every function call
- Prevents log spam when Alpine.js re-renders the form multiple times
- Reduces console noise from 10+ logs per plugin to 1 log per plugin

* fix: Prevent nested section content from sliding out of view when expanded

- Remove overflow-hidden from nested-section in base.html (was causing clipping)
- Add scrollIntoView to scroll expanded sections into view within modal
- Set nested-section overflow to visible to prevent content clipping
- Add min-height to nested-content to ensure proper rendering
- Wait for animation to complete before scrolling into view

* fix: Prevent form-groups from overlapping and appearing outside view

- Change nested-section overflow to hidden by default, visible when expanded
- Add :has() selector to allow overflow when content is expanded
- Ensure form-groups after nested sections have proper spacing and positioning
- Add clear: both and width: 100% to prevent overlap
- Use !important for margin-top to ensure spacing is applied
- Ensure form-groups are in normal document flow with float: none

* fix: Use JavaScript to toggle overflow instead of :has() selector

- :has() selector may not be supported in all browsers
- Use JavaScript to set overflow: visible when expanded, hidden when collapsed
- This ensures better browser compatibility while maintaining functionality

* fix: Make parent sections expand when nested sections expand

- Add updateParentNestedContentHeight() helper to recursively update parent heights
- When a nested section expands, recalculate all parent nested-content max-heights
- Ensures parent sections (like NFL) expand to accommodate expanded child sections
- Updates parent heights both on expand and collapse for proper animation

* refactor: Simplify parent section expansion using CSS max-height: none

- Remove complex recursive parent height update function
- Use CSS max-height: none when expanded to allow natural expansion
- Parent sections automatically expand because nested-content has no height constraint
- Simpler and more maintainable solution

* refactor: Remove complex recursive parent height update function

- CSS max-height: none already handles parent expansion automatically
- No need for JavaScript to manually update parent heights
- Much simpler and cleaner solution

* debug: Add instrumentation to debug auto-collapse issue

- Add logging to track toggle calls and state changes
- Add guard to prevent multiple simultaneous toggles
- Pass event object to prevent bubbling
- Improve state detection logic
- Add return false to onclick handlers

* chore: Remove debug instrumentation from toggleNestedSection

- Remove all debug logging code
- Keep functional fixes: event handling, toggle guard, improved state detection
- Code is now clean and production-ready

* fix(web): Add browser refresh note to plugin fetch errors

* refactor(text-display): Update submodule to use ScrollHelper

* fix(text-display): Fix scrolling display issue - update position in display()

* feat(text-display): Add scroll_loop option and improve scroll speed control

* debug: Add instrumentation to track plugin enabled state changes

Added debug logging to investigate why plugins appear to disable themselves:
- Track enabled state during plugin load (before/after schema merge)
- Track enabled state during plugin reload
- Track enabled state preservation during config save
- Track state reconciliation fixes
- Track enabled state updates in on_config_change

This will help identify which code path is causing plugins to disable.

* debug: Fix debug log path to work on Pi

Changed hardcoded log path to use dynamic project root detection:
- Uses LEDMATRIX_ROOT env var if set
- Falls back to detecting project root by looking for config directory
- Creates .cursor directory if it doesn't exist
- Falls back to /tmp/ledmatrix_debug.log if all else fails
- Added better error handling with logger fallback

* Remove debug instrumentation for plugin enabled state tracking

Removed all debug logging that was added to track plugin enabled state changes.
The instrumentation has been removed as requested.

* Reorganize documentation and cleanup test files

- Move documentation files to docs/ directory
- Remove obsolete test files
- Update .gitignore and README

* feat(text-display): Switch to frame-based scrolling with high FPS support

* fix(text-display): Add backward compatibility for ScrollHelper sub-pixel scrolling

* feat(scroll_helper): Add sub-pixel scrolling support for smooth movement

- Add sub-pixel interpolation using scipy (if available) or numpy fallback
- Add set_sub_pixel_scrolling() method to enable/disable feature
- Implement _get_visible_portion_subpixel() for fractional pixel positioning
- Implement _interpolate_subpixel() for linear interpolation
- Prevents pixel skipping at slow scroll speeds
- Maintains backward compatibility with integer pixel path

* fix(scroll_helper): Reset last_update_time in reset_scroll() to prevent jump-ahead

- Reset last_update_time when resetting scroll position
- Prevents large delta_time on next update after reset
- Fixes issue where scroll would immediately complete again after reset
- Ensures smooth scrolling continuation after loop reset

* fix(scroll_helper): Fix numpy broadcasting error in sub-pixel interpolation

- Add output_width parameter to _interpolate_subpixel() for variable widths
- Fix wrap-around case to use correct widths for interpolation
- Handle edge cases where source array is smaller than expected
- Prevent 'could not broadcast input array' errors in sub-pixel scrolling
- Ensure proper width matching in all interpolation paths

* feat(scroll): Add frame-based scrolling mode for smooth LED matrix movement

- Add frame_based_scrolling flag to ScrollHelper
- When enabled, moves fixed pixels per step, throttled by scroll_delay
- Eliminates time-based jitter by ignoring frame timing variations
- Provides stock-ticker-like smooth, predictable scrolling
- Update text-display plugin to use frame-based mode

This addresses stuttering issues where time-based scrolling caused
visual jitter due to frame timing variations in the main display loop.

* fix(scroll): Fix NumPy broadcasting errors in sub-pixel wrap-around

- Ensure _interpolate_subpixel always returns exactly requested width
- Handle cases where scipy.ndimage.shift produces smaller arrays
- Add padding logic for wrap-around cases when arrays are smaller than expected
- Prevents 'could not broadcast input array' errors during scrolling

* refactor(scroll): Remove sub-pixel interpolation, use high FPS integer scrolling

- Disable sub-pixel scrolling by default in ScrollHelper
- Simplify get_visible_portion to always use integer pixel positioning
- Restore frame-based scrolling logic for smooth high FPS movement
- Use high frame rate (like stock ticker) for smoothness instead of interpolation
- Reduces complexity and eliminates broadcasting errors

* fix(scroll): Prevent large pixel jumps in frame-based scrolling

- Initialize last_step_time properly to prevent huge initial jumps
- Clamp scroll_speed to max 5 pixels/frame in frame-based mode
- Prevents 60-pixel jumps when scroll_speed is misconfigured
- Simplified step calculation to avoid lag catch-up jumps

* fix(text-display): Align config schema and add validation

- Update submodule reference
- Adds warning and logging for scroll_speed config issues

* fix(scroll): Simplify frame-based scrolling to match stock ticker behavior

- Remove throttling logic from frame-based scrolling
- Move pixels every call (DisplayController's loop timing controls rate)
- Add enable_scrolling attribute to text-display plugin for high-FPS treatment
- Matches stock ticker: simple, predictable movement every frame
- Eliminates jitter from timing mismatches between DisplayController and ScrollHelper

* fix(scroll): Restore scroll_delay throttling in frame-based mode

- Restore time-based throttling using scroll_delay
- Move pixels only when scroll_delay has passed
- Handle lag catch-up with reasonable caps to prevent huge jumps
- Preserve fractional timing for smooth operation
- Now scroll_delay actually controls the scroll speed as intended

* feat(text-display): Add FPS counter logging

- Update submodule reference
- Adds FPS tracking and logging every 5 seconds

* fix(text-display): Add display-width buffer so text scrolls completely off

- Update submodule reference
- Adds end buffer to ensure text exits viewport before looping

* fix: Prevent premature game switching in SportsLive

- Set last_game_switch when games load even if current_game already exists
- Set last_game_switch when same games update but it's still 0
- Add guard to prevent switching check when last_game_switch is 0
- Fixes issue where first game shows for only ~2 seconds before switching
- Also fixes random screen flickering when games change prematurely

* feat(plugins): Add branch selection support for plugin installation

- Add optional branch parameter to install_plugin() and install_from_url() in store_manager
- Update API endpoints to accept and pass branch parameter
- Update frontend JavaScript to support branch selection in install calls
- Maintain backward compatibility - branch parameter is optional everywhere
- Falls back to default branch logic if specified branch doesn't exist

* feat(plugins): Add UI for branch selection in plugin installation

- Add branch input field in 'Install Single Plugin' section
- Add global branch input for store installations
- Update JavaScript to read branch from input fields
- Branch input applies to all store installations when specified

* feat(plugins): Change branch selection to be per-plugin instead of global

- Remove global store branch input field
- Add individual branch input field to each plugin card in store
- Add branch input to custom registry plugin cards
- Each plugin can now have its own branch specified independently

* debug: Add logging to _should_exit_dynamic

* feat(display_controller): Add universal get_cycle_duration support for all plugins

UNIVERSAL FEATURE: Any plugin can now implement get_cycle_duration() to dynamically
calculate the total time needed to show all content for a mode.

New method:
- _plugin_cycle_duration(plugin, display_mode): Queries plugin for calculated duration

Integration:
- Display controller calls plugin.get_cycle_duration(display_mode)
- Uses returned duration as target (respecting max cap)
- Falls back to cap if not provided

Benefits:
- Football plugin: Show all games (3 games × 15s = 45s total)
- Basketball plugin: Could implement same logic
- Hockey/Baseball/any sport: Universal support
- Stock ticker: Could calculate based on number of stocks
- Weather: Could calculate based on forecast days

Example plugin implementation:

Result: Plugins control their own display duration based on actual content,
creating a smooth user experience where all content is shown before switching.

* debug: Add logging to cycle duration call

* debug: Change loop exit logs to INFO level

* fix: Change cycle duration logs to INFO level

* fix: Don't exit loop on False for dynamic duration plugins

For plugins with dynamic duration enabled, keep the display loop running
even when display() returns False. This allows games to continue rotating
within the calculated duration.

The loop will only exit when:
- Cycle is complete (plugin reports all content shown)
- Max duration is reached
- Mode is changed externally

* fix(schedule): Improve display scheduling functionality

- Add GET endpoint for schedule configuration retrieval
- Fix mode switching to clean up old config keys (days/start_time/end_time)
- Improve error handling with consistent error_response() usage
- Enhance display controller schedule checking with better edge case handling
- Add validation for time formats and ensure at least one day enabled in per-day mode
- Add debug logging for schedule state changes

Fixes issues where schedule mode switching left stale config causing incorrect behavior.

* fix(install): Add cmake and ninja-build to system dependencies

Resolves h3 package build failure during first-time installation.
The h3 package (dependency of timezonefinder) requires CMake and
Ninja to build from source. Adding these build tools ensures
successful installation of all Python dependencies.

* fix: Pass display_mode in ALL loop calls to maintain sticky manager

CRITICAL FIX: Display controller was only passing display_mode on first call,
causing plugins to fall back to internal mode cycling and bypass sticky
manager logic.

Now consistently passes display_mode=active_mode on every display() call in
both high-FPS and normal loops. This ensures plugins maintain mode context
and sticky manager state throughout the entire display duration.

* feat(install): Add OS check for Raspberry Pi OS Lite (Trixie)

- Verify OS is Raspberry Pi OS (raspbian/debian)
- Require Debian 13 (Trixie) specifically
- Check for Lite version (no desktop environment)
- Exit with clear error message if requirements not met
- Provide instructions for obtaining correct OS version

* fix(web-ui): Add missing notification handlers to quick action buttons

- Added hx-on:htmx:after-request handlers to all quick action buttons in overview.html
- Added hx-ext='json-enc' for proper JSON encoding
- Added missing notification handler for reboot button in index.html
- Users will now see toast notifications when actions complete or fail

* fix(display): Ensure consistent display mode handling in all plugin calls

- Updated display controller to consistently pass display_mode in all plugin display() calls.
- This change maintains the sticky manager state and ensures plugins retain their mode context throughout the display duration.
- Addresses issues with mode cycling and improves overall display reliability.

* fix(display): Enhance display mode persistence across plugin updates

- Updated display controller to ensure display_mode is consistently maintained during plugin updates.
- This change prevents unintended mode resets and improves the reliability of display transitions.
- Addresses issues with mode persistence, ensuring a smoother user experience across all plugins.

* feat: Add Olympics countdown plugin as submodule

- Add olympics-countdown plugin submodule
- Update .gitignore to allow olympics-countdown plugin
- Plugin automatically determines next Olympics and counts down to opening/closing ceremonies

* feat(web-ui): Add checkbox-group widget support for multi-select arrays

- Add checkbox-group widget rendering in plugins_manager.js
- Update form processing to handle checkbox groups with [] naming
- Support for friendly labels via x-options in config schemas
- Update odds-ticker submodule with checkbox-group implementation

* fix(plugins): Preserve enabled state when saving plugin config from main config endpoint

When saving plugin configuration through save_main_config endpoint, the enabled
field was not preserved if missing from the form data. This caused plugins to
be automatically disabled when users saved their configuration from the plugin
manager tab.

This fix adds the same enabled state preservation logic that exists in
save_plugin_config endpoint, ensuring consistent behavior across both endpoints.
The enabled state is preserved from current config, plugin instance, or defaults
to True to prevent unexpected disabling of plugins.

* fix(git): Resolve git status timeout and exclude plugins from base project updates

- Add --untracked-files=no flag to git status for faster execution
- Increase timeout from 5s to 30s for git status operations
- Add timeout exception handling for git status and stash operations
- Filter out plugins directory from git status checks (plugins are separate repos)
- Exclude plugins from stash operations using :!plugins pathspec
- Apply same fixes to plugin store manager update operations

* feat(plugins): Add granular scroll speed control to odds-ticker and leaderboard plugins

- Add display object to both plugins' config schemas with scroll_speed and scroll_delay
- Enable frame-based scrolling mode for precise FPS control (100 FPS for leaderboard)
- Add set_scroll_speed() and set_scroll_delay() methods to both plugins
- Maintain backward compatibility with scroll_pixels_per_second config
- Leaderboard plugin now explicitly sets target_fps to 100 for high-performance scrolling

* fix(scroll): Correct dynamic duration calculation for frame-based scrolling

- Fix calculate_dynamic_duration() to properly handle frame-based scrolling mode
- Convert scroll_speed from pixels/frame to pixels/second when in frame-based mode
- Prevents incorrect duration calculations (e.g., 2609s instead of 52s)
- Affects all plugins using ScrollHelper: odds-ticker, leaderboard, stocks, text-display
- Add debug logging to show scroll mode and effective speed

* Remove version logic from plugin system, use git commits instead

- Remove version parameter from install_plugin() method
- Rename fetch_latest_versions to fetch_commit_info throughout codebase
- Remove version fields from plugins.json registry (versions, latest_version, download_url_template)
- Remove version logging from plugin manager
- Update web UI to use fetch_commit_info parameter
- Update .gitignore to ignore all plugin folders (remove whitelist exceptions)
- Remove plugin directories from git index (plugins now installed via plugin store only)

Plugins now always install latest commit from default branch. Version fields
replaced with git commit SHA and commit dates. System uses git-based approach
for all plugin metadata.

* feat(plugins): Normalize all plugins as git submodules

- Convert all 18 plugins to git submodules for uniform management
- Add submodules for: baseball-scoreboard, christmas-countdown, football-scoreboard, hockey-scoreboard, ledmatrix-flights, ledmatrix-leaderboard, ledmatrix-music, ledmatrix-stocks, ledmatrix-weather, static-image
- Re-initialize mqtt-notifications as proper submodule
- Update .gitignore to allow all plugin submodules
- Add normalize_plugin_submodules.sh script for future plugin management

All plugins with GitHub repositories are now managed as git submodules,
ensuring consistent version control and easier updates.

* refactor(repository): Reorganize scripts and files into organized directory structure

- Move installation scripts to scripts/install/ (except first_time_install.sh)
- Move development scripts to scripts/dev/
- Move utility scripts to scripts/utils/
- Move systemd service files to systemd/
- Keep first_time_install.sh, start_display.sh, stop_display.sh in root
- Update all path references in scripts, documentation, and service files
- Add README.md files to new directories explaining their purpose
- Remove empty tools/ directory (contents moved to scripts/dev/)
- Add .gitkeep to data/ directory

* fix(scripts): Fix PROJECT_DIR path in start_web_conditionally.py after move to scripts/utils/

* fix(scripts): Fix PROJECT_DIR/PROJECT_ROOT path resolution in moved scripts

- Fix wifi_monitor_daemon.py to use project root instead of scripts/utils/
- Fix shell scripts in scripts/ to correctly resolve project root (go up one more level)
- Fix scripts in scripts/fix_perms/ to correctly resolve project root
- Update diagnose_web_interface.sh to reference moved start_web_conditionally.py path

All scripts now correctly determine project root after reorganization.

* fix(install): Update first_time_install.sh to detect and update service files with old paths

- Check for old paths in service files and reinstall if needed
- Always reinstall main service (install_service.sh is idempotent)
- This ensures existing installations get updated paths after reorganization

* fix(install): Update install_service.sh message to indicate it updates existing services

* fix(wifi): Enable WiFi scan to work when AP mode is active

- Temporarily disable AP mode during network scanning
- Automatically re-enable AP mode after scan completes
- Add proper error handling with try/finally to ensure AP mode restoration
- Add user notification when AP mode is temporarily disabled
- Improve error messages for common scanning failures
- Add timing delays for interface mode switching

* fix(wifi): Fix network parsing to handle frequency with 'MHz' suffix

- Strip 'MHz' suffix from frequency field before float conversion
- Add better error logging for parsing failures
- Fixes issue where all networks were silently skipped due to ValueError

* debug(wifi): Add console logging and Alpine.js reactivity fixes for network display

- Add console.log statements to debug network scanning
- Add x-effect to force Alpine.js reactivity updates
- Add unique keys to x-for template
- Add debug display showing network count
- Improve error handling and user feedback

* fix(wifi): Manually update select options instead of using Alpine.js x-for

- Replace Alpine.js x-for template with manual DOM manipulation
- Add updateSelectOptions() method to directly update select dropdown
- This fixes issue where networks weren't appearing in dropdown
- Alpine.js x-for inside select elements can be unreliable

* feat(web-ui): Add patternProperties support for dynamic key-value pairs

- Add UI support for patternProperties objects (custom_feeds, feed_logo_map)
- Implement key-value pair editor with add/remove functionality
- Add JavaScript functions for managing dynamic key-value pairs
- Update form submission to handle patternProperties JSON data
- Enable easy configuration of feed_logo_map in web UI

* chore: Update ledmatrix-news submodule to latest commit

* fix(plugins): Handle arrays of objects in config normalization

Fix configuration validation failure for static-image plugin by adding
recursive normalization support for arrays of objects. The normalize_config_values
function now properly handles arrays containing objects (like image_config.images)
by recursively normalizing each object in the array using the items schema properties.

This resolves the 'configuration validation failed' error when saving static
image plugin configuration with multiple images.

* fix(plugins): Handle union types in config normalization and form generation

Fix configuration validation for fields with union types like ['integer', 'null'].
The normalization function now properly handles:
- Union types in top-level fields (e.g., random_seed: ['integer', 'null'])
- Union types in array items
- Empty string to None conversion for nullable fields
- Form generation and submission for union types

This resolves validation errors when saving plugin configs with nullable
integer/number fields (e.g., rotation_settings.random_seed in static-image plugin).

Also improves UX by:
- Adding placeholder text for nullable fields explaining empty = use default
- Properly handling empty values in form submission for union types

* fix(plugins): Improve union type normalization with better edge case handling

Enhanced normalization for union types like ['integer', 'null']:
- Better handling of whitespace in string values
- More robust empty string to None conversion
- Fallback to None when conversion fails and null is allowed
- Added debug logging for troubleshooting normalization issues
- Improved handling of nested object fields with union types

This should resolve remaining validation errors for nullable integer/number
fields in nested objects (e.g., rotation_settings.random_seed).

* chore: Add ledmatrix-news plugin to .gitignore exceptions

* Fix web interface service script path in install_service.sh

- Updated ExecStart path from start_web_conditionally.py to scripts/utils/start_web_conditionally.py
- Updated diagnose_web_ui.sh to check for correct script path
- Fixes issue where web UI service fails to start due to incorrect script path

* Fix nested configuration section headers not expanding

Fixed toggleNestedSection function to properly calculate scrollHeight when
expanding nested configuration sections. The issue occurred when sections
started with display:none - the scrollHeight was being measured before the
browser had a chance to lay out the element, resulting in a value of 0.

Changes:
- Added setTimeout to delay scrollHeight measurement until after layout
- Added overflow handling during animations to prevent content jumping
- Added fallback for edge cases where scrollHeight might still be 0
- Set maxHeight to 'none' after expansion completes for natural growth
- Updated function in both base.html and plugins_manager.js

This fix applies to all plugins with nested configuration sections, including:
- Hockey/Football/Basketball/Baseball/Soccer scoreboards (customization, global sections)
- All plugins with transition, display, and other nested configuration objects

Fixes configuration header expansion issues across all plugins.

* Fix syntax error in first_time_install.sh step 8.5

Added missing 'fi' statement to close the if block in the WiFi monitor
service installation section. This resolves the 'unexpected end of file'
error that occurred at line 1385 during step 8.5.

* Fix WiFi UI: Display correct SSID and accurate signal strength

- Fix WiFi network selection dropdown not showing available networks
  - Replace manual DOM manipulation with Alpine.js x-for directive
  - Add fallback watcher to ensure select updates reactively

- Fix WiFi status display showing netplan connection name instead of SSID
  - Query actual SSID from device properties (802-11-wireless.ssid)
  - Add fallback methods to get SSID from active WiFi connection list

- Improve signal strength accuracy
  - Get signal directly from device properties (WIFI.SIGNAL)
  - Add multiple fallback methods for robust signal retrieval
  - Ensure signal percentage is accurate and up-to-date

* Improve WiFi connection UI and error handling

- Fix connect button disabled condition to check both selectedSSID and manualSSID
- Improve error handling to display actual server error messages from 400 responses
- Add step-by-step labels (Step 1, Step 2, Step 3) to clarify connection workflow
- Add visual feedback showing selected network in blue highlight box
- Improve password field labeling with helpful instructions
- Add auto-clear logic between dropdown and manual SSID entry
- Enhance backend validation with better error messages and logging
- Trim SSID whitespace before processing to prevent validation errors

* Add WiFi disconnect functionality for AP mode testing

- Add disconnect_from_network() method to WiFiManager
  - Disconnects from current WiFi network using nmcli
  - Automatically triggers AP mode check if auto_enable_ap_mode is enabled
  - Returns success/error status with descriptive messages

- Add /api/v3/wifi/disconnect API endpoint
  - POST endpoint to disconnect from current WiFi network
  - Includes proper error handling and logging

- Add disconnect button to WiFi status section
  - Only visible when connected to a network
  - Red styling to indicate disconnection action
  - Shows 'Disconnecting...' state during operation
  - Automatically refreshes status after disconnect

- Integrates with AP mode auto-enable functionality
  - When disconnected, automatically enables AP mode if configured
  - Perfect for testing captive portal and AP mode features

* Add explicit handling for broken pipe errors during plugin dependency installation

- Catch BrokenPipeError and OSError (errno 32) explicitly in all dependency installation methods
- Add clear error messages explaining network interruption or buffer overflow causes
- Improves error handling in store_manager, plugin_loader, and plugin_manager
- Helps diagnose 'Errno 32 Broken Pipe' errors during pip install operations

* Add WiFi permissions configuration script and integrate into first-time install

- Create configure_wifi_permissions.sh script
  - Configures passwordless sudo for nmcli commands
  - Configures PolicyKit rules for NetworkManager control
  - Fixes 'Not Authorized to control Networking' error
  - Allows web interface to connect/disconnect WiFi without password prompts

- Integrate WiFi permissions configuration into first_time_install.sh
  - Added as Step 10.1 after passwordless sudo configuration
  - Runs automatically during first-time installation
  - Ensures WiFi management works out of the box

- Resolves authorization errors when connecting/disconnecting WiFi networks
  - NetworkManager requires both sudo and PolicyKit permissions
  - Script configures both automatically for seamless WiFi management

* Add WiFi status LED message display integration

- Integrate WiFi status messages from wifi_manager into display_controller
- WiFi status messages interrupt normal rotation (but respect on-demand)
- Priority: on-demand > wifi-status > live-priority > normal rotation
- Safe implementation with comprehensive error handling
- Automatic cleanup of expired/corrupted status files
- Word-wrapping for long messages (max 2 lines)
- Centered text display with small font
- Non-intrusive: all errors are caught and logged, never crash controller

* Fix display loop issues: reduce log spam and handle missing plugins

- Change _should_exit_dynamic logging from INFO to DEBUG to reduce log spam
  in tight loops (every 8ms) that was causing high CPU usage
- Fix display loop not running when manager_to_display is None
- Add explicit check to set display_result=False when no plugin manager found
- Fix logic bug where manager_to_display was overwritten after circuit breaker skip
- Ensure proper mode rotation when plugins have no content or aren't found

* Add debug logging to diagnose display loop stuck issue

* Change debug logs to INFO level to diagnose display loop stuck

* Add schedule activation logging and ensure display is blanked when inactive

- Add clear INFO-level log message when schedule makes display inactive
- Track previous display state to detect schedule transitions
- Clear display when schedule makes it inactive to ensure blank screen
  (prevents showing initialization screen when schedule kicks in)
- Initialize _was_display_active state tracking in __init__

* Fix indentation errors in schedule state tracking

* Add rotation between hostname and IP address every 10 seconds

- Added _get_local_ip() method to detect device IP address
- Implemented automatic rotation between hostname and IP every 10 seconds
- Enhanced logging to include both hostname and IP in initialization
- Updated get_info() to expose device_ip and current_display_mode

* Add WiFi connection failsafe system

- Save original connection before attempting new connection
- Automatically restore original connection if new connection fails
- Enable AP mode as last resort if restoration fails
- Enhanced connection verification with multiple attempts
- Verify correct SSID (not just 'connected' status)
- Better error handling and exception recovery
- Prevents Pi from becoming unresponsive on connection failure
- Always ensures device remains accessible via original WiFi or AP mode

* feat(web): Improve web UI startup speed and fix cache permissions

- Defer plugin discovery until first API request (removed from startup)
- Add lazy loading to operation queue, state manager, and operation history
- Defer health monitor initialization until first request
- Fix cache directory permission issue:
  - Add systemd CacheDirectory feature for automatic cache dir creation
  - Add manual cache directory creation in install script as fallback
  - Improve cache manager logging (reduce alarming warnings)
- Fix syntax errors in wifi_manager.py (unclosed try blocks)

These changes significantly improve web UI startup time, especially with many
plugins installed, while maintaining full backward compatibility.

* feat(plugins): Improve GitHub token pop-up UX and combine warning/settings

- Fix visibility toggle to handle inline styles properly
- Remove redundant inline styles from HTML elements
- Combine warning banner and settings panel into unified component
- Add loading states to save/load token buttons
- Improve error handling with better user feedback
- Add token format validation (ghp_ or github_pat_ prefix)
- Auto-refresh GitHub auth status after saving token
- Hide warning banner when settings panel opens
- Clear input field after successful save for security

This creates a smoother UX flow where clicking 'Configure Token'
transitions from warning directly to configuration form.

* fix(wifi): Prevent WiFi radio disabling during AP mode disable

- Make NetworkManager restart conditional (only for hostapd mode)
- Add enhanced WiFi radio enable with retry and verification logic
- Add connectivity safety check before NetworkManager restart
- Ensure WiFi radio enabled after all AP mode disable operations
- Fix indentation bug in dnsmasq backup restoration logic
- Add pre-connection WiFi radio check for safety

Fixes issue where WiFi radio was being disabled when disabling AP mode,
especially when connected via Ethernet, making it impossible to enable
WiFi from the web UI.

* fix(plugin-templates): Fix unreachable fallback to expired cache in update() method

The exception handler in update() checked the cached variable, which would
always be None or falsy at that point. If fresh cached data existed, the
method returned early. If cached data was expired, it was filtered out by
max_age constraint. The fix retrieves cached data again in the exception
handler with a very large max_age (1 year) to effectively bypass expiration
check and allow fallback to expired data when fetch fails.

* fix(plugin-templates): Resolve plugin_id mismatch in test template setUp method

* feat(plugins): Standardize manifest version fields schema

- Consolidate version fields to use consistent naming:
  - compatible_versions: array of semver ranges (required)
  - min_ledmatrix_version: string (optional)
  - max_ledmatrix_version: string (optional)
  - versions[].ledmatrix_min_version: renamed from ledmatrix_min
- Add manifest schema validation (schema/manifest_schema.json)
- Update store_manager to validate version fields and schema
- Update template and all documentation examples to use standardized fields
- Add deprecation warnings for ledmatrix_version and ledmatrix_min fields

* fix(templates): Update plugin README template script path to correct location

* docs(plugin): Resolve conflicting version management guidance in .cursorrules

* chore(.gitignore): Consolidate plugin exclusion patterns

Remove unnecessary !plugins/*/.git pattern and consolidate duplicate
negations by keeping only trailing-slash directory exclusions.

* docs: Add language specifiers to code blocks in STATIC_IMAGE_MULTI_UPLOAD_PLAN.md

* fix(templates): Remove api_key from config.json example in plugin README template

Remove api_key field from config.json example to prevent credential leakage.
API keys should only be stored in config_secrets.json. Added clarifying note
about proper credential storage.

* docs(README): Add plugin installation and migration information

- Add plugin installation instructions via web interface and GitHub URL
- Add plugin migration guide for users upgrading from old managers
- Improve plugin documentation for new users

* docs(readme): Update donation links and add Discord acknowledgment

* docs: Add comprehensive API references and consolidate documentation

- Add API_REFERENCE.md with complete REST API documentation (50+ endpoints)
- Add PLUGIN_API_REFERENCE.md documenting Display Manager, Cache Manager, and Plugin Manager APIs
- Add ADVANCED_PLUGIN_DEVELOPMENT.md with advanced patterns and examples
- Add DEVELOPER_QUICK_REFERENCE.md for quick developer reference
- Consolidate plugin configuration docs into single PLUGIN_CONFIGURATION_GUIDE.md
- Archive completed implementation summaries to docs/archive/
- Enhance PLUGIN_DEVELOPMENT_GUIDE.md with API links and 3rd party submission guidelines
- Update docs/README.md with new API reference sections
- Update root README.md with documentation links

* fix(install): Fix IP detection and network diagnostics after fresh install

- Fix web-ui-info plugin IP detection to handle no internet, AP mode, and network state changes
- Replace socket-based detection with robust interface scanning using hostname -I and ip addr
- Add AP mode detection returning 192.168.4.1 when AP mode is active
- Add periodic IP refresh every 30 seconds to handle network state changes
- Improve network diagnostics in first_time_install.sh showing actual IPs, WiFi status, and AP mode
- Add WiFi connection check in WiFi monitor installation with warnings
- Enhance web service startup logging to show accessible IP addresses
- Update README with network troubleshooting section and fix port references (5001->5000)

Fixes issue where display showed incorrect IP (127.0.11:5000) and users couldn't access web UI after fresh install.

* chore: Add GitHub sponsor button configuration

* fix(wifi): Fix aggressive AP mode enabling and improve WiFi detection

Critical fixes:
- Change auto_enable_ap_mode default from True to False (manual enable only)
- Fixes issue where Pi would disconnect from network after code updates
- Matches documented behavior (was incorrectly defaulting to True in code)

Improvements:
- Add grace period: require 3 consecutive disconnected checks (90s) before enabling AP mode
- Prevents AP mode from enabling on transient network hiccups
- Improve WiFi status detection with retry logic and better nmcli parsing
- Enhanced logging for debugging WiFi connection issues
- Better handling of WiFi device detection (works with any wlan device)

This prevents the WiFi monitor from aggressively enabling AP mode and
disconnecting the Pi from the network when there are brief network issues
or during system initialization.

* fix(wifi): Revert auto_enable_ap_mode default to True with grace period protection

Change default back to True for auto_enable_ap_mode while keeping the grace
period protection that prevents interrupting valid WiFi connections.

- Default auto_enable_ap_mode back to True (useful for setup scenarios)
- Grace period (3 consecutive checks = 90s) prevents false positives
- Improved WiFi detection with retry logic ensures accurate status
- AP mode will auto-enable when truly disconnected, but won't interrupt
  valid connections due to transient detection issues

* fix(news): Update submodule reference for manifest fix

Update ledmatrix-news submodule to include the fixed manifest.json with
required entry_point and class_name fields.

* fix(news): Update submodule reference with validate_config addition

Update ledmatrix-news submodule to include validate_config method for
proper configuration validation.

* feat: Add of-the-day plugin as git submodule

- Add ledmatrix-of-the-day plugin as git submodule
- Rename submodule path from plugins/of-the-day to plugins/ledmatrix-of-the-day to match repository naming convention
- Update .gitignore to allow ledmatrix-of-the-day submodule
- Plugin includes fixes for display rendering and web UI configuration support

* fix(wifi): Make AP mode open network and fix WiFi page loading in AP mode

AP Mode Changes:
- Remove password requirement from AP mode (open network for easier setup)
- Update hostapd config to create open network (no WPA/WPA2)
- Update nmcli hotspot to create open network (no password parameter)

WiFi Page Loading Fixes:
- Download local copies of HTMX and Alpine.js libraries
- Auto-detect AP mode (192.168.4.x) and use local JS files instead of CDN
- Auto-open WiFi tab when accessing via AP mode IP
- Add fallback loading if HTMX fails to load
- Ensures WiFi setup page works in AP mode without internet access

This fixes the issue where the WiFi page wouldn't load on iPhone when
accessing via AP mode (192.168.4.1:5000) because CDN resources couldn't
be fetched without internet connectivity.

* feat(wifi): Add explicit network switching support with clean disconnection

WiFi Manager Improvements:
- Explicitly disconnect from current network before connecting to a new one
- Add skip_ap_check parameter to disconnect_from_network() to prevent AP mode
  from activating during network switches
- Check if already connected to target network to avoid unnecessary work
- Improved logging for network switching operations

Web UI Improvements:
- Detect and display network switching status in UI
- Show 'Switching from [old] to [new]...' message when switching networks
- Enhanced status reloading after connection (multiple checks at 2s, 5s, 10s)
- Better user feedback during network transitions

This ensures clean network switching without AP mode interruptions and
provides clear feedback to users when changing WiFi networks.

* fix(web-ui): Add fallback content loading when HTMX fails to load

Problem:
- After recent updates, web UI showed navigation and CPU status but main
  content tabs never loaded
- Content tabs depend on HTMX's 'revealed' trigger to load
- If HTMX failed to load or initialize, content would never appear

Solutions:
- Enhanced HTMX loading verification with timeout checks
- Added fallback direct fetch for overview tab if HTMX fails
- Added automatic tab content loading when tabs change
- Added loadTabContent() method to manually trigger content loading
- Added global 'htmx-load-failed' event for error handling
- Automatic retry after 5 seconds if HTMX isn't available
- Better error messages and console logging for debugging

This ensures the web UI loads content even if HTMX has issues,
providing graceful degradation and better user experience.

* feat(web-ui): Add support for plugin custom HTML widgets and static file serving

- Add x-widget: custom-html support in config schema generation
- Add loadCustomHtmlWidget() function to load HTML from plugin directories
- Add /api/v3/plugins/<plugin_id>/static/<file_path> endpoint for serving plugin static files
- Enhance execute_plugin_action() to pass params via stdin as JSON for scripts
- Add JSON output parsing for script action responses

These changes enable plugins to provide custom UI components while keeping
all functionality plugin-scoped. Used by of-the-day plugin for file management.

* fix(web-ui): Resolve Alpine.js initialization errors

- Prevent Alpine.js from auto-initializing before app() function is defined
- Add deferLoadingAlpine to ensure proper initialization order
- Make app() function globally available via window.app
- Fix 'app is not defined' and 'activeTab is not defined' errors
- Remove duplicate Alpine.start() calls that caused double initialization warnings

* fix(web-ui): Fix IndentationError in api_v3.py OAuth flow

- Fix indentation in if action_def.get('oauth_flow') block
- Properly indent try/except block and all nested code
- Resolves IndentationError that prevented web interface from starting

* fix(web-ui): Fix SyntaxError in api_v3.py else block

- Fix indentation of OAuth flow code inside else block
- Properly indent else block for simple script execution
- Resolves SyntaxError at line 3458 that prevented web interface from starting

* fix(web-ui): Restructure OAuth flow check to fix SyntaxError

- Move OAuth flow check before script execution in else block
- Remove unreachable code that was causing syntax error
- OAuth check now happens first, then falls back to script execution
- Resolves SyntaxError at line 3458

* fix(web-ui): Define app() function in head for Alpine.js initialization

- Define minimal app() function in head before Alpine.js loads
- Ensures app() is available when Alpine initializes
- Full implementation in body enhances/replaces the stub
- Fixes 'app is not defined' and 'activeTab is not defined' errors

* fix(web-ui): Ensure plugin tabs load when full app() implementation is available

- Update stub init() to detect and use full implementation when available
- Ensure full implementation properly replaces stub methods
- Call init() after merging to load plugins and set up watchers
- Fixes issue where installed plugins weren't showing in navigation bar

* fix(web-ui): Prevent 'Cannot redefine property' error for installedPlugins

- Check if window.installedPlugins property already exists before defining
- Make property configurable to allow redefinition if needed
- Add _initialized flag to prevent multiple init() calls
- Fixes TypeError when stub tries to enhance with full implementation

* fix(web-ui): Fix variable redeclaration errors in logs tab

- Replace let/const declarations with window properties to avoid redeclaration
- Use window._logsEventSource, window._allLogs, etc. to persist across HTMX reloads
- Clean up existing event source before reinitializing
- Remove and re-add event listeners to prevent duplicates
- Fixes 'Identifier has already been declared' error when accessing logs tab multiple times

* feat(web-ui): Add support for additionalProperties object rendering

- Add handler for objects with additionalProperties containing object schemas
- Render dynamic category controls with enable/disable toggles
- Display category metadata (display name, data file path)
- Used by of-the-day plugin for category management

* fix(wifi): Ensure AP mode hotspot is always open (no password)

Problem:
- LEDMatrix-Setup WiFi AP was still asking for password despite code changes
- Existing hotspot connections with passwords weren't being fully cleaned up
- NetworkManager might reuse old connection profiles with passwords

Solutions:
- More thorough cleanup: Delete all hotspot-related connections, not just known names
- Verification: Check if hotspot has password after creation
- Automatic fix: Remove password and restart connection if security is detected
- Better logging: Log when password is detected and removed

This ensures the AP mode hotspot is always open for easy setup access,
even if there were previously saved connections with passwords.

* fix(wifi): Improve network switching reliability and device state handling

Problem:
- Pi failing to switch WiFi networks via web UI
- Connection attempts happening before device is ready
- Disconnect not fully completing before new connection attempt
- Connection name lookup issues when SSID doesn't match connection name

Solutions:
- Improved disconnect logic: Disconnect specific connection first, then device
- Device state verification: Wait for device to be ready (disconnected/unavailable) before connecting
- Better connection lookup: Search by SSID, not just connection name
- Increased wait times: 2 seconds for disconnect to complete
- State checking before activating existing connections
- Enhanced error handling and logging throughout

This ensures network switching works reliably by properly managing device
state transitions and using correct connection identifiers.

* debug(web-ui): Add debug logging for custom HTML widget loading

- Add console logging to track widget generation
- Improve error messages with missing configuration details
- Help diagnose why file manager widget may not be appearing

* fix(web-ui): Fix [object Object] display in categories field

- Add type checking to ensure category values are strings before rendering
- Safely extract data_file and display_name properties
- Prevent object coercion issues in category display

* perf(web-ui): Optimize plugin loading in navigation bar

- Reduce stub init timeout from 100ms to 10ms for faster enhancement
- Change full implementation merge from 50ms setTimeout to requestAnimationFrame
- Add direct plugin loading in stub while waiting for full implementation
- Skip plugin reload in full implementation if already loaded by stub
- Significantly improves plugin tab loading speed in navigation bar

* feat(web-ui): Adapt file-upload widget for JSON files in of-the-day plugin

- Add specialized JSON upload/delete endpoints for of-the-day plugin
- Modify file-upload widget to support JSON files (file_type: json)
- Render JSON files with file-code icon instead of image preview
- Show entry count for JSON files
- Store files in plugins/ledmatrix-of-the-day/of_the_day/ directory
- Automatically update categories config when files are uploaded/deleted
- Populate uploaded_files array from categories on form load
- Remove custom HTML widget, use standard file-upload widget instead

* fix(web-ui): Add working updatePluginTabs to stub for immediate plugin tab rendering

- Stub's updatePluginTabs was empty, preventing tabs from showing
- Add basic implementation that creates plugin tabs in navigation bar
- Ensures plugin tabs appear immediately when plugins load, even before full implementation merges
- Fixes issue where plugin navigation bar wasn't working

* feat(api): Populate uploaded_files and categories from disk for of-the-day plugin

- Scan of_the_day directory for existing JSON files when loading config
- Populate uploaded_files array from files on disk
- Populate categories from files on disk if not in config
- Categories default to disabled, user can enable them
- Ensures existing JSON files (word_of_the_day.json, slovenian_word_of_the_day.json) appear in UI

* fix(api): Improve category merging logic for of-the-day plugin

- Preserve existing category enabled state when merging with files from disk
- Ensure all JSON files from disk appear in categories section
- Categories from files default to disabled, preserving user choices
- Properly merge existing config with scanned files

* fix(wifi): More aggressive password removal for AP mode hotspot

Problem:
- LEDMatrix-Setup network still asking for password despite previous fixes
- NetworkManager may add default security settings to hotspots
- Existing connections with passwords may not be fully cleaned up

Solutions:
- Always remove ALL security settings after creating hotspot (not just when detected)
- Remove multiple security settings: key-mgmt, psk, wep-key, auth-alg
- Verify security was removed and recreate connection if verification fails
- Improved cleanup: Delete connections by SSID match, not just by name
- Disconnect connections before deleting them
- Always restart connection after removing security to apply changes
- Better logging for debugging

This ensures the AP mode hotspot is always open, even if NetworkManager
tries to add default security settings.

* perf(web): Optimize web interface performance and fix JavaScript errors

- Add resource hints (preconnect, dns-prefetch) for CDN resources to reduce DNS lookup delays
- Fix duplicate response parsing bug in loadPluginConfig that was parsing JSON twice
- Replace direct fetch() calls with PluginAPI.getInstalledPlugins() to leverage caching and throttling
- Fix Alpine.js function availability issues with defensive checks and $nextTick
- Enhance request deduplication with debug logging and statistics
- Add response caching headers for static assets and API responses
- Add performance monitoring utilities with detailed metrics

Fixes console errors for loadPluginConfig and generateConfigForm not being defined.
Reduces duplicate API calls to /api/v3/plugins/installed endpoint.
Improves initial page load time with resource hints and optimized JavaScript loading.

* perf(web-ui): optimize CSS for Raspberry Pi performance

- Remove backdrop-filter blur from modal-backdrop
- Remove box-shadow transitions (use transform/opacity only)
- Remove button ::before pseudo-element animation
- Simplify skeleton loader (gradient to opacity pulse)
- Optimize transition utility (specific properties, not 'all')
- Improve color contrast for WCAG AA compliance
- Add CSS containment to cards, plugin-cards, modals
- Remove unused CSS classes (duration-300, divider, divider-light)
- Remove duplicate spacing utility classes

All animations now GPU-accelerated (transform/opacity only).
Optimized for low-powered Raspberry Pi devices.

* fix(web): Resolve ReferenceError for getInstalledPluginsSafe in v3 stub initialization

Move getInstalledPluginsSafe() function definition before the app() stub code that uses it. The function was previously defined at line 3756 but was being called at line 849 during Alpine.js initialization, causing a ReferenceError when loadInstalledPluginsDirectly() attempted to load plugins before the full implementation was ready.

* fix(web): Resolve TypeError for installedPlugins.map in plugin loading

Fix PluginAPI.getInstalledPlugins() to properly extract plugins array from API response structure. The API returns {status: 'success', data: {plugins: [...]}}, but the method was returning response.data (the object) instead of response.data.plugins (the array).

Changes:
- api_client.js: Extract plugins array from response.data.plugins
- plugins_manager.js: Add defensive array checks and handle array return value correctly
- base.html: Add defensive check in getInstalledPluginsSafe() to ensure plugins is always an array

This prevents 'installedPlugins.map is not a function' errors when loading plugins.

* style(web-ui): Enhance navigation bar styling for better readability

- Improve contrast: Change inactive tab text from gray-500 to gray-700
- Add gradient background and thicker border for active tabs
- Enhance hover states with background highlights
- Add smooth transitions using GPU-accelerated properties
- Update all navigation buttons (system tabs and plugin tabs)
- Add updatePluginTabStates() method for dynamic tab state management

All changes are CSS-only with zero performance overhead.

* fix(web-ui): Optimize plugin loading and reduce initialization errors

- Make generateConfigForm accessible to inline Alpine components via parent scope
- Consolidate plugin initialization to prevent duplicate API calls
- Fix script execution from HTMX-loaded content by extracting scripts before DOM insertion
- Add request deduplication to loadInstalledPlugins() to prevent concurrent requests
- Improve Alpine component initialization with proper guards and fallbacks

This eliminates 'generateConfigForm is not defined' errors and reduces plugin
API calls from 3-4 duplicate calls to 1 per page load, significantly improving
page load performance.

* fix(web-ui): Add guard check for generateConfigForm to prevent Alpine errors

Add typeof check in x-show to prevent Alpine from evaluating generateConfigForm
before the component methods are fully initialized. This eliminates the
'generateConfigForm is not defined' error that was occurring during component
initialization.

* fix(web-ui): Fix try-catch block structure in script execution code

Correct the nesting of try-catch block inside the if statement for script execution.
The catch block was incorrectly placed after the else clause, causing a syntax error.

* fix(web-ui): Escape quotes in querySelector to avoid HTML attribute conflicts

Change double quotes to single quotes in the CSS selector to prevent conflicts
with HTML attribute parsing when the x-data expression is embedded.

* style(web): Improve button text readability in Quick Actions section

* fix(web): Resolve Alpine.js expression errors in plugin configuration component

- Capture plugin from parent scope into component data to fix parsing errors
- Update all plugin references to use this.plugin in component methods
- Fix x-init to properly call loadPluginConfig method
- Resolves 'Uncaught ReferenceError' for isOnDemandLoading, onDemandLastUpdated, and other component properties

* fix(web): Fix remaining Alpine.js scope issues in plugin configuration

- Use this.generateConfigForm in typeof checks and method calls
- Fix form submission to use this.plugin.id
- Use $root. prefix for parent scope function calls (refreshPlugin, updatePlugin, etc.)
- Fix confirm dialog string interpolation
- Ensures all component methods and properties are properly scoped

* fix(web): Add this. prefix to all Alpine.js component property references

- Fix all template expressions to use this. prefix for component properties
- Update isOnDemandLoading, onDemandLastUpdated, onDemandRefreshing references
- Update onDemandStatusClass, onDemandStatusText, onDemandServiceClass, onDemandServiceText
- Update disableRunButton, canStopOnDemand, showEnableHint, loading references
- Ensures Alpine.js can properly resolve all component getters and properties

* fix(web): Resolve Alpine.js expression errors in plugin configuration

- Move complex x-data object to pluginConfigData() function for better parsing
- Fix all template expressions to use this.plugin instead of plugin
- Add this. prefix to all method calls in event handlers
- Fix duplicate x-on:click attribute on uninstall button
- Add proper loading state management in loadPluginConfig method

This resolves the 'Invalid or unexpected token' and 'Uncaught ReferenceError'
errors in the browser console.

* fix(web): Fix plugin undefined errors in Alpine.js plugin configuration

- Change x-data initialization to capture plugin from loop scope first
- Use Object.assign in x-init to merge pluginConfigData properties
- Add safety check in pluginConfigData function for undefined plugins
- Ensure plugin is available before accessing properties in expressions

This resolves the 'Cannot read properties of undefined' errors by ensuring
the plugin object is properly captured from the x-for loop scope before
any template expressions try to access it.

* style(web): Make Quick Actions button text styling consistent

- Update Start Display, Stop Display, and Reboot System buttons
- Change from text-sm font-medium to text-base font-semibold
- All Quick Actions buttons now have consistent bold, larger text
- Matches the styling of Update Code, Restart Display Service, and Restart Web Service buttons

* fix(wifi): Properly handle AP mode disable during WiFi connection

- Check return value of disable_ap_mode() before proceeding with connection
- Add verification loop to ensure AP mode is actually disabled
- Increase wait time to 5 seconds for NetworkManager restart stabilization
- Return clear error messages if AP mode cannot be disabled
- Prevents connection failures when switching networks from web UI or AP mode

This fixes the issue where WiFi network switching would fail silently when
AP mode disable failed, leaving the system in an inconsistent state.

* fix(web): Handle API response errors in plugin configuration loading

- Add null/undefined checks before accessing API response status
- Set fallback defaults when API responses don't have status 'success'
- Add error handling for batch API requests with fallback to individual requests
- Add .catch() handlers to individual fetch calls to prevent unhandled rejections
- Add console warnings to help debug API response failures
- Fix applies to both main loadPluginConfig and PluginConfigHelpers.loadPluginConfig

This fixes the issue where plugin configuration sections would get stuck
showing the loading animation when API responses failed or returned error status.

* fix(web): Fix Alpine.js reactivity for plugin config by using direct x-data

Changed from Object.assign pattern to direct x-data assignment to ensure
Alpine.js properly tracks reactive properties. The previous approach used
Object.assign to merge properties into the component after initialization,
which caused Alpine to not detect changes to config/schema properties.

The fix uses pluginConfigData(plugin) directly as x-data, ensuring all
properties including config, schema, loading, etc. are reactive from
component initialization.

* fix(web): Ensure plugin variable is captured in x-data scope

Use spread operator to merge pluginConfigData properties while explicitly
capturing the plugin variable from outer x-for scope. This fixes undefined
plugin errors when Alpine evaluates the component data.

* fix(web): Use $data for Alpine.js reactivity when merging plugin config

Use Object.assign with Alpine's $data reactive proxy instead of this to
ensure added properties are properly reactive. This fixes the issue where
plugin variable scoping from x-for wasn't accessible in x-data expressions.

* fix(web): Remove incorrect 'this.' prefix in Alpine.js template expressions

Alpine.js template expressions (x-show, x-html, x-text, x-on) use the
component data as the implicit context, so 'this.' prefix is incorrect.
In template expressions, 'this' refers to the DOM element, not the
component data.

Changes:
- Replace 'this.plugin.' with 'plugin.' in all template expressions (19 instances)
- Replace 'this.loading' with 'loading' in x-show directives
- Replace 'this.generateConfigForm' with 'generateConfigForm' in x-show/x-html
- Replace 'this.savePluginConfig' with 'savePluginConfig' in x-on:submit
- Replace 'this.config/schema/webUiActions' with direct property access
- Use '$data.loadPluginConfig' in x-init for explicit method call

Note: 'this.' is still correct inside JavaScript method definitions within
pluginConfigData() function since those run with proper object context.

* fix(web): Prevent infinite recursion in plugin config methods

Add 'parent !== this' check to loadPluginConfig, generateConfigForm, and
savePluginConfig methods in pluginConfigData to prevent infinite recursion
when the component tries to delegate to a parent that resolves to itself.

This fixes the 'Maximum call stack size exceeded' error that occurred when
the nested Alpine component's $root reference resolved to a component that
had the same delegating methods via Object.assign.

* fix(web): Resolve infinite recursion in plugin config by calling $root directly

The previous implementation had delegating methods (generateConfigForm,
savePluginConfig) in pluginConfigData that tried to call parent.method(),
but the parent detection via getParentApp() was causing circular calls
because multiple components had the same methods.

Changes:
- Template now calls $root.generateConfigForm() and $root.savePluginConfig()
  directly instead of going through nested component delegation
- Removed delegating generateConfigForm and savePluginConfig from pluginConfigData
- Removed getParentApp() helper that was enabling the circular calls
- Simplified loadPluginConfig to use PluginConfigHelpers directly

This fixes the 'Maximum call stack size exceeded' error when rendering
plugin configuration forms.

* fix(web): Use window.PluginConfigHelpers instead of $root for plugin config

The $root magic variable in Alpine.js doesn't correctly reference the
app() component's data scope from nested x-data contexts. This causes
generateConfigForm and savePluginConfig to be undefined.

Changed to use window.PluginConfigHelpers which has explicit logic to
find and use the app component's methods.

* fix(web): Use direct x-data initialization for plugin config reactivity

Changed from Object.assign($data, pluginConfigData(plugin)) to
x-data="pluginConfigData(plugin)" to ensure Alpine.js properly
tracks reactivity for all plugin config properties. This fixes
the issue where all plugin tabs were showing the same config.

* refactor(web): Implement server-side plugin config rendering with HTMX

Major architectural improvement to plugin configuration management:

- Add server-side Jinja2 template for plugin config forms
  (web_interface/templates/v3/partials/plugin_config.html)
- Add Flask route to serve plugin config partials on-demand
- Replace complex client-side form generation with HTMX lazy loading
- Add Alpine.js store for centralized plugin state management
- Mark old pluginConfigData and PluginConfigHelpers as deprecated

Benefits:
- Lazy loading: configs only load when tab is accessed
- Server-side rendering: reduces client-side complexity
- Better performance: especially on Raspberry Pi
- Cleaner code: Jinja2 macros replace JS string templates
- More maintainable: form logic in one place (server)

The old client-side code is preserved for backwards compatibility
but is no longer used by the main plugin configuration UI.

* fix(web): Trigger HTMX manually after Alpine renders plugin tabs

HTMX processes attributes at page load time, before Alpine.js
renders dynamic content. Changed from :hx-get attribute to
x-init with htmx.ajax() to properly trigger the request after
the element is rendered.

* fix(web): Remove duplicate 'enabled' toggle from plugin config form

The 'enabled' field was appearing twice in plugin configuration:
1. Header toggle (quick action, uses HTMX)
2. Configuration form (from schema, requires save)

Now only the header toggle is shown, avoiding user confusion.
The 'enabled' key is explicitly skipped when rendering schema properties.

* perf(web): Optimize plugin manager with request caching and init guards

Major performance improvements to plugins_manager.js:

1. Request Deduplication & Caching
   - Added pluginLoadCache with 3-second TTL
   - Subsequent calls return cached data instead of making API requests
   - In-flight request deduplication prevents parallel duplicate fetches
   - Added refreshInstalledPlugins() for explicit force-refresh

2. Initialization Guards
   - Added pluginsInitialized flag to prevent multiple initializePlugins() calls
   - Added _eventDelegationSetup guard on container to prevent duplicate listeners
   - Added _listenerSetup guards on search/category inputs

3. Debug Logging Control
   - Added PLUGIN_DEBUG flag (localStorage.setItem('pluginDebug', 'true'))
   - Most console.log calls now use pluginLog() which only logs when debug enabled
   - Reduces console noise from ~150 logs to ~10 in production

Expected improvements:
- API calls reduced from 6+ to 2 on page load
- Event listeners no longer duplicated
- Cleaner console output
- Faster perceived performance

* fix(web): Handle missing search elements in searchPluginStore

The searchPluginStore function was failing silently when called before
the plugin-search and plugin-category elements existed in the DOM.
This caused the plugin store to never load.

Now safely checks if elements exist before accessing their values.

* fix(web): Ensure plugin store loads via pluginManager.searchPluginStore

- Exposed searchPluginStore on window.pluginManager for easier access
- Updated base.html to fallback to pluginManager.searchPluginStore
- Added logging when loading plugin store

* fix(web): Expose searchPluginStore from inside the IIFE

The function was defined inside the IIFE but only exposed after the IIFE
ended, where the function was out of scope. Now exposed immediately after
definition inside the IIFE.

* fix(web): Add cache-busting version to plugins_manager.js URL

Static JS files were being aggressively cached, preventing updates
from being loaded by browsers.

* fix(web): Fix pluginLog reference error outside IIFE

pluginLog is defined inside the IIFE, so use _PLUGIN_DEBUG_EARLY and
console.log directly for code outside the IIFE.

* chore(web): Update plugins_manager.js cache version

* fix(web): Defer plugin store render when grid not ready

Instead of showing an error when plugin-store-grid doesn't exist,
store plugins in window.__pendingStorePlugins for later rendering
when the tab loads (consistent with how installed plugins work).

* chore: Bump JS cache version

* fix(web): Restore enabledBool variable in plugin render

Variable was removed during debug logging optimization but was still
being used in the template string for toggle switch rendering.

* fix(ui): Add header and improve categories section rendering

- Add proper header (h4) to categories section with label
- Add debug logging to diagnose categories field rendering
- Improve additionalProperties condition check readability

* fix(ui): Improve additionalProperties condition check

- Explicitly exclude objects with properties to avoid conflicts
- Ensure categories section is properly detected and rendered
- Categories should show as header with toggles, not text box

* fix(web-ui): Fix JSON parsing errors and default value loading for plugin configs

- Fix JSON parsing errors when saving file upload fields by properly unescaping HTML entities
- Merge config with schema defaults when loading plugin config so form shows default values
- Improve default value handling in form generation for nested objects and arrays
- Add better error handling for malformed JSON in file upload fields

* fix(plugins): Return plugins array from getInstalledPlugins() instead of data object

Fixed PluginAPI.getInstalledPlugins() to return response.data.plugins (array)
instead of response.data (object). This was preventing window.installedPlugins
from being set correctly, which caused plugin configuration tabs to not appear
and prevented users from saving plugin configurations via the web UI.

The fix ensures that:
- window.installedPlugins is properly populated with plugin array
- Plugin tabs are created automatically on page load
- Configuration forms and save buttons are rendered correctly
- Save functionality works as expected

* fix(api): Support form data submission for plugin config saves

The HTMX form submissions use application/x-www-form-urlencoded format
instead of JSON. This update allows the /api/v3/plugins/config POST
endpoint to accept both formats:

- JSON: plugin_id and config in request body (existing behavior)
- Form data: plugin_id from query string, config fields from form

Added _parse_form_value helper to properly convert form strings to
appropriate Python types (bool, int, float, JSON arrays/objects).

* debug: Add form data logging to diagnose config save issue

* fix(web): Re-discover plugins before loading config partial

The plugin config partial was returning 'not found' for plugins
because the plugin manifests weren't loaded. The installed plugins
API was working because it calls discover_plugins() first.

Changes:
- Add discover_plugins() call in _load_plugin_config_partial when
  plugin info is not found on first try
- Remove debug logging from form data handling

* fix(web): Comprehensive plugin config save improvements

SWEEPING FIX for plugin configuration saving issues:

1. Form data now MERGES with existing config instead of replacing
   - Partial form submissions (missing fields) no longer wipe out
     existing config values
   - Fixes plugins with complex schemas (football, clock, etc.)

2. Improved nested value handling with _set_nested_value helper
   - Correctly handles deeply nested structures like customization
   - Properly merges when intermediate objects already exist

3. Better JSON parsing for arrays
   - RGB color arrays like [255, 0, 0] now parse correctly
   - Parse JSON before trying number conversion

4. Bump cache version to force JS reload

* fix(web): Add early stubs for updatePlugin and uninstallPlugin

Ensures these functions are available immediately when the page loads,
even before the full IIFE executes. Provides immediate user feedback
and makes API calls directly.

This fixes the 'Update button does not work' issue by ensuring the
function is always defined and callable.

* fix(web): Support form data in toggle endpoint

The toggle endpoint now accepts both JSON and HTMX form submissions.
Also updated the plugin config template to send the enabled state
via hx-vals when the checkbox changes.

Fixes: 415 Unsupported Media Type error when toggling plugins

* fix(web): Prevent config duplication when toggling plugins

Changed handleToggleResponse to update UI in place instead of
refreshing the entire config partial, which was causing duplication.

Also improved refreshPluginConfig with proper container targeting
and concurrent refresh prevention (though it's no longer needed
for toggles since we update in place).

* fix(api): Schema-aware form value parsing for plugin configs

Major fix for plugin config saving issues:

1. Load schema BEFORE processing form data to enable type-aware parsing
2. New _parse_form_value_with_schema() function that:
   - Converts comma-separated strings to arrays when schema says 'array'
   - Parses JSON strings for arrays/objects
   - Handles empty strings for arrays (returns [] instead of None)
   - Uses schema to determine correct number types
3. Post-processing to ensure None arrays get converted to empty arrays
4. Proper handling of nested object fields

Fixes validation errors:
- 'category_order': Expected type array, got str
- 'categories': Expected type object, got str
- 'uploaded_files': Expected type array, got NoneType
- RGB color arrays: Expected type array, got str

* fix(web): Make plugin config handlers idempotent and remove scripts from HTMX partials

CRITICAL FIX for script redeclaration errors:

1. Removed all <script> tags from plugin_config.html partial
   - Scripts were being re-executed on every HTMX swap
   - Caused 'Identifier already declared' errors

2. Moved all handler functions to base.html with idempotent initialization
   - Added window.__pluginConfigHandlersInitialized guard
   - Functions only initialized once, even if script runs multiple times
   - All state stored on window object (e.g., window.pluginConfigRefreshInProgress)

3. Enhanced error logging:
   - Client-side: Logs form payload, response status, and parsed error details
   - Server-side: Logs raw form data and parsed config on validation failures

4. Functions moved to window scope:
   - toggleSection
   - handleConfigSave (with detailed error logging)
   - handleToggleResponse (updates UI in place, no refresh)
   - handlePluginUpdate
   - refreshPluginConfig (with duplicate prevention)
   - runPluginOnDemand
   - stopOnDemand
   - executePluginAction

This ensures HTMX-swapped fragments only contain HTML, and all
scripts run once in the base layout.

* fix(api): Filter config to only schema-defined fields before validation

When merging with existing_config, fields not in the plugin's schema
(like high_performance_transitions, transition, dynamic_duration)
were being preserved, causing validation failures when
additionalProperties is false.

Add _filter_config_by_schema() function to recursively filter config
to only include fields defined in the schema before validation.

This fixes validation errors like:
- 'Additional properties are not allowed (high_performance_transitions, transition were unexpected)'

* fix(web): Improve update plugin error handling and support form data

1. Enhanced updatePlugin JavaScript function:
   - Validates pluginId before sending request
   - Checks response.ok before parsing JSON
   - Better error logging with request/response details
   - Handles both successful and error responses properly

2. Update endpoint now supports both JSON and form data:
   - Similar to config endpoint, accepts plugin_id from query string or form
   - Better error messages and debug logging

3. Prevent duplicate function definitions:
   - Second updatePlugin definition checks if improved version exists
   - Both definitions now have consistent error handling

Fixes: 400 BAD REQUEST 'Request body must be valid JSON' error

* fix(web): Show correct 'update' message instead of 'save' for plugin updates

The handlePluginUpdate function now:
1. Checks actual HTTP status code (not just event.detail.successful)
2. Parses JSON response to get server's actual message
3. Replaces 'save' with 'update' if message incorrectly says 'save'

Fixes: Update button showing 'saved successfully' instead of
'updated successfully'

* fix(web): Execute plugin updates immediately instead of queuing

Plugin updates are now executed directly (synchronously) instead of
being queued for async processing. This provides immediate feedback
to users about whether the update succeeded or failed.

Updates are fast git pull operations, so they don't need async
processing. The operation queue is reserved for longer operations
like install/uninstall.

Fixes: Update button not actually updating plugins (operations were
queued but users didn't see results)

* fix(web): Ensure toggleSection function is always available for collapsible headers

Moved toggleSection outside the initialization guard block so it's
always defined, even if the plugin config handlers have already been
initialized. This ensures collapsible sections in plugin config forms
work correctly.

Added debug logging to help diagnose if sections/icons aren't found.

Fixes: Collapsible headers in plugin config schema not collapsing

* fix(web): Improve toggleSection to explicitly show/hide collapsible content

Changed from classList.toggle() to explicit add/remove of 'hidden' class
based on current state. This ensures the content visibility is properly
controlled when collapsing/expanding sections.

Added better error checking and state detection for more reliable
collapsible section behavior.

* fix(web): Load plugin tabs on page load instead of waiting for plugin manager tab click

The stub's loadInstalledPlugins was an empty function, so plugin tabs
weren't loading until the plugin manager tab was clicked. Now the stub
implementation:
1. Tries to use global window.loadInstalledPlugins if available
2. Falls back to window.pluginManager.loadInstalledPlugins
3. Finally falls back to direct loading via loadInstalledPluginsDirectly
4. Always updates tabs after loading plugins

This ensures plugin navigation tabs are available immediately on page load.

Fixes: Plugin tabs only loading after clicking plugin manager tab

* fix(web): Ensure plugin navigation tabs load on any page regardless of active tab

Multiple improvements to ensure plugin tabs are always visible:

1. Stub's loadInstalledPluginsDirectly now waits for DOM to be ready
   before updating tabs, using requestAnimationFrame for proper timing

2. Stub's init() now has a retry mechanism that periodically checks
   if plugins have been loaded by plugins_manager.js and updates tabs
   accordingly (checks for 2 seconds)

3. Full implementation's init() now properly handles async plugin loading
   and ensures tabs are updated after loading completes, checking
   window.installedPlugins first before attempting to load

4. Both stub and full implementation ensure tabs update using $nextTick
   to wait for Alpine.js rendering cycle

This ensures plugin navigation tabs are visible immediately when the
page loads, regardless of whether the user is on overview, plugin manager,
or any other tab.

Fixes: Plugin tabs only appearing after clicking plugin manager tab

* fix(web): Fix restart display button not working

The initPluginsPage function was returning early before event listeners
were set up, making all the event listener code unreachable. Moved the
return statement to after all event listeners are attached.

This fixes the restart display button and all other buttons in the
plugin manager (refresh plugins, update all, search, etc.) that depend
on event listeners being set up.

Fixes: Restart Display button not working in plugin manager

* fix(web-ui): Improve categories field rendering for of-the-day plugin

- Add more explicit condition checking for additionalProperties objects
- Add debug logging specifically for categories field
- Add fallback handler for objects that don't match special cases (render as JSON textarea)
- Ensure categories section displays correctly with toggle cards instead of plain text

* fix(install): Prevent following broken symlinks during file ownership setup

- Add -P flag to find commands to prevent following symlinks when traversing
- Add -h flag to chown to operate on symlinks themselves rather than targets
- Exclude scripts/dev/plugins directory which contains development symlinks
- Fixes error when chown tries to dereference broken symlinks with extra LEDMatrix in path

* fix(scroll): Ensure scroll completes fully before switching displays

- Add display_width to total scroll distance calculation
- Scroll now continues until content is completely off screen
- Update scroll completion check to use total_scroll_width + display_width
- Prevents scroll from being cut off mid-way when switching to next display

* fix(install): Remove unsupported -P flag from find commands

- Remove -P flag which is not supported on all find versions
- Keep -h flag on chown to operate on symlinks themselves
- Change to {} \; syntax for better error handling
- Add error suppression to continue on broken symlinks
- Exclude scripts/dev/plugins directory to prevent traversal into broken symlinks

* docs(wifi): Add trailing newline to WiFi AP failover setup guide

* fix(web): Suppress non-critical socket errors and fix WiFi permissions script

- Add error filtering in web interface to suppress harmless client disconnection errors
- Downgrade 'No route to host' and broken pipe errors from ERROR to DEBUG level
- Fix WiFi permissions script to use mktemp instead of manual temp file creation
- Add cleanup trap to ensure temp files are removed on script exit
- Resolves permission denied errors when creating temp files during installation

* fix(web): Ensure plugin navigation tabs load on any page by dispatching events

The issue was that when plugins_manager.js loaded and called
loadInstalledPlugins(), it would set window.installedPlugins but the
Alpine.js component wouldn't know to update its tabs unless the plugin
manager tab was clicked.

Changes:
1. loadInstalledPlugins() now always dispatches a 'pluginsUpdated' event
   when it sets window.installedPlugins, not just when plugin IDs change
2. renderInstalledPlugins() also dispatches the event and always updates
   window.installedPlugins for reactivity
3. Cached plugin data also dispatches the event when returned

The Alpine component already listens for the 'pluginsUpdated' event in
its init() method, so tabs will now update immediately when plugins are
loaded, regardless of which tab is active.

Fixes: Plugin navigation tabs only loading after clicking plugin manager tab

* fix(web): Improve input field contrast in plugin configuration forms

Changed input backgrounds from bg-gray-800 to bg-gray-900 (darker) to
ensure high contrast with white text. Added placeholder:text-gray-400
for better placeholder text visibility.

Updated in both server-side template (plugin_config.html) and client-side
form generation (plugins_manager.js):
- Number inputs
- Text inputs
- Array inputs (comma-separated)
- Select dropdowns
- Textareas (JSON objects)
- Fallback inputs without schema

This ensures all form inputs have high contrast white text on dark
background, making them clearly visible and readable.

Fixes: White text on white background in plugin config inputs

* fix(web): Change plugin config input text from white to black

Changed all input fields in plugin configuration forms to use black text
on white background instead of white text on dark background for better
readability and standard form appearance.

Updated:
- Input backgrounds: bg-gray-900 -> bg-white
- Text color: text-white -> text-black
- Placeholder color: text-gray-400 -> text-gray-500

Applied to both server-side template and client-side form generation
for all input types (number, text, select, textarea).

* fix(web): Ensure toggleSection function is available for plugin config collapsible sections

Moved toggleSection function definition to an early script block so it's
available immediately when HTMX loads plugin configuration content. The
function was previously defined later in the page which could cause it
to not be accessible when inline onclick handlers try to call it.

The function toggles the 'hidden' class on collapsible section content
divs and rotates the chevron icon between right (collapsed) and down
(expanded) states.

Fixes: Plugin configuration section headers not collapsing/expanding

* fix(web): Fix collapsible section toggle to properly hide/show content

Updated toggleSection function to explicitly set display style in addition
to toggling the hidden class. This ensures the content is properly hidden
even if CSS specificity or other styles might interfere with just the
hidden class.

The function now:
- Checks both the hidden class and computed display style
- Explicitly sets display: '' when showing and display: 'none' when hiding
- Rotates chevron icon between right (collapsed) and down (expanded)

This ensures collapsible sections in plugin configuration forms properly
hide and show their content when the header is clicked.

Fixes: Collapsible section headers rotate chevron but don't hide content

* fix(web): Fix collapsible section toggle to work on first click

Simplified the toggle logic to rely primarily on the 'hidden' class check
rather than mixing it with computed display styles. When hiding, we now
remove any inline display style to let Tailwind's 'hidden' class properly
control the display property.

This ensures sections respond correctly on the first click, whether they're
starting in a collapsed or expanded state.

Fixes: Sections requiring 2 clicks to collapse

* fix(web): Ensure collapsible sections start collapsed by default

Added explicit display: none style to nested content divs in plugin config
template to ensure they start collapsed. The hidden class should handle this,
but adding the inline style ensures sections are definitely collapsed on
initial page load.

Sections now:
- Start collapsed (hidden) with chevron pointing right
- Expand when clicked (chevron points down)
- Collapse when clicked again (chevron points right)

This ensures a consistent collapsed initial state across all plugin
configuration sections.

* fix(web): Fix collapsible section toggle to properly collapse on second click

Fixed the toggle logic to explicitly set display: block when showing and
display: none when hiding, rather than clearing the display style. This
ensures the section state is properly tracked and the toggle works correctly
on both expand and collapse clicks.

The function now:
- When hidden: removes hidden class, sets display: block, chevron down
- When visible: adds hidden class, sets display: none, chevron right

This fixes the issue where sections would expand but not collapse again.

Fixes: Sections not collapsing on second click

* feat(web): Ensure plugin navigation tabs load automatically on any page

Implemented comprehensive solution to ensure plugin navigation tabs load
automatically without requiring a visit to the plugin manager page:

1. Global event listener for 'pluginsUpdated' - works even if Alpine isn't
   ready yet, updates tabs directly when plugins_manager.js loads plugins

2. Enhanced stub's loadInstalledPluginsDirectly():
   - Sets window.installedPlugins after loading
   - Dispatches 'pluginsUpdated' event for global listener
   - Adds console logging for debugging

3. Event listener in stub's init() method:
   - Listens for 'pluginsUpdated' events
   - Updates component state and tabs when events fire

4. Fallback timer:
   - If plugins_manager.js hasn't loaded after 2 seconds, fetches
     plugins directly via API
   - Ensures tabs appear even if plugins_manager.js fails

5. Improved checkAndUpdateTabs():
   - Better logging
   - Fallback to direct fetch after timeout

6. Enhanced logging throughout plugin loading flow for debugging

This ensures plugin tabs are visible immediately on page load, regardless
of which tab is active or when plugins_manager.js loads.

Fixes: Plugin navigation tabs only loading after visiting plugin manager

* fix(web): Improve plugin tabs update logging and ensure immediate execution

Enhanced logging in updatePluginTabs() and _doUpdatePluginTabs() to help
debug why tabs aren't appearing. Changed debounce behavior to execute
immediately on first call to ensure tabs appear quickly.

Added detailed console logging with [FULL] prefix to track:
- When updatePluginTabs() is called
- When _doUpdatePluginTabs() executes
- DOM element availability
- Tab creation process
- Final tab count

This will help identify if tabs are being created but not visible, or if
the update function isn't being called at all.

Fixes: Plugin tabs loading but not visible in navigation bar

* fix(web): Prevent duplicate plugin tab updates and clearing

Added debouncing and duplicate prevention to stub's updatePluginTabs() to
prevent tabs from being cleared and re-added multiple times. Also checks
if tabs already match before clearing them.

Changes:
1. Debounce stub's updatePluginTabs() with 100ms delay
2. Check if existing tabs match current plugin list before clearing
3. Global event listener only triggers full implementation's updatePluginTabs
4. Stub's event listener only works in stub mode (before enhancement)

This prevents the issue where tabs were being cleared and re-added
multiple times in rapid succession, which could leave tabs empty.

Fixes: Plugin tabs being cleared and not re-added properly

* fix(web): Fix plugin tabs not rendering when plugins are loaded

Fixed _doUpdatePluginTabs() to properly use component's installedPlugins
instead of checking window.installedPlugins first. Also fixed the 'unchanged'
check to not skip when both lists are empty (first load scenario).

Changes:
1. Check component's installedPlugins first (most up-to-date)
2. Only skip update if plugins exist AND match (don't skip empty lists)
3. Retry if no plugins found (in case they're still loading)
4. Ensure window.installedPlugins is set when loading directly
5. Better logging to show which plugin source is being used

This ensures tabs are rendered when plugins are loaded, even on first page load.

Fixes: Plugin tabs not being drawn despite plugins being loaded

* fix(config): Fix array field parsing and validation for plugin config forms

- Added logic to detect and combine indexed array fields (text_color.0, text_color.1, etc.)
- Fixed array fields incorrectly stored as dicts with numeric keys
- Improved handling of comma-separated array values from form submissions
- Ensures array fields meet minItems requirements before validation
- Resolves 400 BAD REQUEST errors when saving plugin config with RGB color arrays

* fix(config): Improve array field handling and secrets error handling

- Use schema defaults when array fields don't meet minItems requirement
- Add debug logging for array field parsing
- Improve error handling for secrets file writes
- Fix arrays stored as dicts with numeric keys conversion
- Better handling of incomplete array values from form submissions

* fix(config): Convert array elements to correct types (numbers not strings)

- Fix array element type conversion when converting dicts to arrays
- Ensure RGB color arrays have integer elements, not strings
- Apply type conversion for both nested and top-level array fields
- Fixes validation errors: 'Expected type number, got str'

* fix(config): Fix array fields showing 'none' when value is null

- Handle None/null values in array field templates properly
- Use schema defaults when array values are None/null
- Fix applies to both Jinja2 template and JavaScript form generation
- Resolves issue where stock ticker plugin shows 'none' instead of default values

* fix(config): Add novalidate to plugin config form to prevent HTML5 validation blocking saves

- Prevents browser HTML5 validation from blocking form submission
- Allows custom validation logic to handle form data properly
- Fixes issue where save button appears unclickable due to invalid form controls
- Resolves problems with plugins like clock-simple that have nested/array fields

* feat(config): Add helpful form validation with detailed error messages

- Keep HTML5 validation enabled (removed novalidate) to prevent broken configs
- Add validatePluginConfigForm function that shows which fields fail and why
- Automatically expands collapsed sections containing invalid fields
- Focuses first invalid field and scrolls to it
- Shows user-friendly error messages with field names and specific issues
- Prevents form submission until all fields are valid

* fix(schema): Remove core properties from required array during validation

- Core properties (enabled, display_duration, live_priority) are system-managed
- SchemaManager now removes them from required array after injection
- Added default values for core properties (enabled=True, display_duration=15, live_priority=False)
- Updated generate_default_config() to ensure live_priority has default
- Resolves 186 validation issues, reducing to 3 non-blocking warnings (98.4% reduction)
- All 19 of 20 plugins now pass validation without errors

Documentation:
- Created docs/PLUGIN_CONFIG_CORE_PROPERTIES.md explaining core property handling
- Updated existing docs to reflect core property behavior
- Removed temporary audit files and scripts

* fix(ui): Improve button text contrast on white backgrounds

- Changed Screenshot button text from text-gray-700 to text-gray-900
- Added global CSS rule to ensure all buttons with white backgrounds use dark text (text-gray-900) for better readability
- Fixes contrast issues where light text on light backgrounds was illegible

* fix(ui): Add explicit text color to form-control inputs

- Added color: #111827 to .form-control class to ensure dark text on white backgrounds
- Fixes issue where input fields had white text on white background after button contrast fix
- Ensures all form inputs are readable with proper contrast

* docs: Update impact explanation and plugin config documentation

* docs: Improve documentation and fix template inconsistencies

- Add migration guide for script path reorganization (scripts moved to scripts/install/ and scripts/fix_perms/)
- Add breaking changes section to README with migration guidance
- Fix config template: set plugins_directory to 'plugins' to match actual plugin locations
- Fix test template: replace Jinja2 placeholders with plain text to match other templates
- Fix markdown linting: add language identifiers to code blocks (python, text, javascript)
- Update permission guide: document setgid bit (0o2775) for directory modes
- Fix example JSON: pin dependency versions and fix compatible_versions range
- Improve readability: reduce repetition in IMPACT_EXPLANATION.md

* feat(web): Make v3 interface production-ready for local deployment

- Phase 2: Real Service Integration
  - Replace sample data with real psutil system monitoring (CPU, memory, disk, temp, uptime)
  - Integrate display controller to read from /tmp/led_matrix_preview.png snapshot
  - Scan assets/fonts directory and extract font metadata with freetype

- Phase 1: Security & Input Validation
  - Add input validation module with URL, file upload, and config sanitization
  - Add optional CSRF protection (gracefully degrades if flask-wtf missing)
  - Add rate limiting (lenient for local use, prevents accidental abuse)
  - Add file upload validation to font upload endpoint

- Phase 3: Error Handling
  - Add global error handlers for 404, 500, and unhandled exceptions
  - All endpoints have comprehensive try/except blocks

- Phase 4: Monitoring & Observability
  - Add structured logging with JSON format support
  - Add request logging middleware (tracks method, path, status, duration, IP)
  - Add /api/v3/health endpoint with service status checks

- Phase 5: Performance & Caching
  - Add in-memory caching system (separate module to avoid circular imports)
  - Cache font catalog (5 minute TTL)
  - Cache system status (10 second TTL)
  - Invalidate cache on config changes

- All changes are non-blocking with graceful error handling
- Optional dependencies (flask-wtf, flask-limiter) degrade gracefully
- All imports protected with try/except blocks
- Verified compilation and import tests pass

* docs: Fix caching pattern logic flaw and merge conflict resolution plan

- Fix Basic Caching Pattern: Replace broken stale cache fallback with correct pattern
  - Re-fetch cache with large max_age (31536000) in except block instead of checking already-falsy cached variable
  - Fixes both instances in ADVANCED_PLUGIN_DEVELOPMENT.md
  - Matches correct pattern from manager.py.template

- Fix MERGE_CONFLICT_RESOLUTION_PLAN.md merge direction
  - Correct Step 1 to checkout main and merge plugins into it (not vice versa)
  - Update commit message to reflect 'Merge plugins into main' direction
  - Fixes workflow to match documented plugins → main merge

---------

Co-authored-by: Chuck <chuck@example.com>
This commit is contained in:
Chuck
2025-12-27 14:15:49 -05:00
committed by GitHub
parent 711482d59a
commit 7d71656cf1
647 changed files with 83039 additions and 1199386 deletions

281
scripts/analyze_plugin_schemas.py Executable file
View File

@@ -0,0 +1,281 @@
#!/usr/bin/env python3
"""
Analyze all plugin config schemas to identify issues:
- Duplicate fields
- Inconsistencies
- Missing common fields
- Naming variations
- Formatting issues
"""
import json
import os
from pathlib import Path
from typing import Dict, List, Set, Any
import jsonschema
from jsonschema import Draft7Validator
# Standard common fields that should be in all plugins
STANDARD_COMMON_FIELDS = {
"enabled": {
"type": "boolean",
"default": False,
"description": "Enable or disable this plugin",
"required": True,
"order": 1
},
"display_duration": {
"type": "number",
"default": 15,
"minimum": 1,
"maximum": 300,
"description": "How long to display this plugin in seconds",
"order": 2
},
"live_priority": {
"type": "boolean",
"default": False,
"description": "Enable live priority takeover when plugin has live content",
"order": 3
},
"high_performance_transitions": {
"type": "boolean",
"default": False,
"description": "Use high-performance transitions (120 FPS) instead of standard (30 FPS)",
"order": 4
},
"update_interval": {
"type": "integer",
"default": 60,
"minimum": 1,
"description": "How often to refresh data in seconds",
"order": 5
},
"transition": {
"type": "object",
"order": 6
}
}
def find_duplicate_fields(schema: Dict[str, Any], path: str = "") -> List[str]:
"""Find duplicate field definitions within a schema."""
duplicates = []
seen_fields = {}
def check_properties(props: Dict[str, Any], current_path: str):
if not isinstance(props, dict):
return
for key, value in props.items():
full_path = f"{current_path}.{key}" if current_path else key
if key in seen_fields:
duplicates.append(f"Duplicate field '{key}' at {full_path} (also at {seen_fields[key]})")
else:
seen_fields[key] = full_path
# Recursively check nested objects
if isinstance(value, dict):
if "properties" in value:
check_properties(value["properties"], full_path)
elif "items" in value and isinstance(value["items"], dict):
if "properties" in value["items"]:
check_properties(value["items"]["properties"], f"{full_path}[items]")
if "properties" in schema:
check_properties(schema["properties"], "")
return duplicates
def validate_schema_syntax(schema_path: Path) -> tuple[bool, List[str]]:
"""Validate JSON Schema syntax."""
errors = []
try:
with open(schema_path, 'r', encoding='utf-8') as f:
schema = json.load(f)
# Validate schema structure
Draft7Validator.check_schema(schema)
return True, []
except json.JSONDecodeError as e:
return False, [f"JSON syntax error: {str(e)}"]
except jsonschema.SchemaError as e:
return False, [f"Schema validation error: {str(e)}"]
except Exception as e:
return False, [f"Error: {str(e)}"]
def analyze_schema(schema_path: Path) -> Dict[str, Any]:
"""Analyze a single schema file."""
plugin_id = schema_path.parent.name
analysis = {
"plugin_id": plugin_id,
"path": str(schema_path),
"valid": False,
"errors": [],
"warnings": [],
"has_title": False,
"has_description": False,
"common_fields": {},
"missing_common_fields": [],
"naming_issues": [],
"duplicates": [],
"property_order": [],
"update_interval_variant": None
}
try:
with open(schema_path, 'r', encoding='utf-8') as f:
schema = json.load(f)
# Check for title and description
analysis["has_title"] = "title" in schema
analysis["has_description"] = "description" in schema
if not analysis["has_title"]:
analysis["warnings"].append("Missing 'title' field at root level")
if not analysis["has_description"]:
analysis["warnings"].append("Missing 'description' field at root level")
# Validate schema syntax
is_valid, errors = validate_schema_syntax(schema_path)
analysis["valid"] = is_valid
analysis["errors"].extend(errors)
if not is_valid:
return analysis
# Check for duplicate fields
duplicates = find_duplicate_fields(schema)
analysis["duplicates"] = duplicates
# Check properties
if "properties" not in schema:
analysis["errors"].append("Missing 'properties' field")
return analysis
properties = schema["properties"]
# Check common fields
for field_name, field_spec in STANDARD_COMMON_FIELDS.items():
if field_name in properties:
analysis["common_fields"][field_name] = properties[field_name]
else:
# Check for variants
if field_name == "update_interval":
# Check for update_interval_seconds variant
if "update_interval_seconds" in properties:
analysis["update_interval_variant"] = "update_interval_seconds"
analysis["naming_issues"].append(
f"Uses 'update_interval_seconds' instead of 'update_interval'"
)
else:
analysis["missing_common_fields"].append(field_name)
else:
analysis["missing_common_fields"].append(field_name)
# Check property order (enabled should be first)
prop_keys = list(properties.keys())
analysis["property_order"] = prop_keys
if prop_keys and prop_keys[0] != "enabled":
analysis["warnings"].append(
f"'enabled' is not first property. First property is '{prop_keys[0]}'"
)
# Check for required fields
required = schema.get("required", [])
if "enabled" not in required:
analysis["warnings"].append("'enabled' is not in required fields")
except Exception as e:
analysis["errors"].append(f"Failed to analyze schema: {str(e)}")
return analysis
def main():
"""Main analysis function."""
project_root = Path(__file__).parent.parent
plugins_dir = project_root / "plugins"
if not plugins_dir.exists():
print(f"Plugins directory not found: {plugins_dir}")
return
results = []
# Find all config_schema.json files
schema_files = list(plugins_dir.glob("*/config_schema.json"))
print(f"Found {len(schema_files)} plugin schemas to analyze\n")
for schema_path in sorted(schema_files):
print(f"Analyzing {schema_path.parent.name}...")
analysis = analyze_schema(schema_path)
results.append(analysis)
# Print summary
print("\n" + "="*80)
print("ANALYSIS SUMMARY")
print("="*80)
for result in results:
print(f"\n{result['plugin_id']}:")
print(f" Valid: {result['valid']}")
if result['errors']:
print(f" Errors ({len(result['errors'])}):")
for error in result['errors']:
print(f" - {error}")
if result['warnings']:
print(f" Warnings ({len(result['warnings'])}):")
for warning in result['warnings']:
print(f" - {warning}")
if result['duplicates']:
print(f" Duplicates ({len(result['duplicates'])}):")
for dup in result['duplicates']:
print(f" - {dup}")
if result['missing_common_fields']:
print(f" Missing common fields: {', '.join(result['missing_common_fields'])}")
if result['naming_issues']:
print(f" Naming issues:")
for issue in result['naming_issues']:
print(f" - {issue}")
if result['property_order'] and result['property_order'][0] != 'enabled':
print(f" Property order: First is '{result['property_order'][0]}' (should be 'enabled')")
# Overall statistics
print("\n" + "="*80)
print("OVERALL STATISTICS")
print("="*80)
valid_count = sum(1 for r in results if r['valid'])
has_title_count = sum(1 for r in results if r['has_title'])
has_description_count = sum(1 for r in results if r['has_description'])
enabled_first_count = sum(1 for r in results if r['property_order'] and r['property_order'][0] == 'enabled')
total_errors = sum(len(r['errors']) for r in results)
total_warnings = sum(len(r['warnings']) for r in results)
total_duplicates = sum(len(r['duplicates']) for r in results)
print(f"Total plugins: {len(results)}")
print(f"Valid schemas: {valid_count}/{len(results)}")
print(f"Has title: {has_title_count}/{len(results)}")
print(f"Has description: {has_description_count}/{len(results)}")
print(f"'enabled' first: {enabled_first_count}/{len(results)}")
print(f"Total errors: {total_errors}")
print(f"Total warnings: {total_warnings}")
print(f"Total duplicates: {total_duplicates}")
# Save detailed report
report_path = project_root / "plugin_schema_analysis.json"
with open(report_path, 'w', encoding='utf-8') as f:
json.dump(results, f, indent=2)
print(f"\nDetailed report saved to: {report_path}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,293 @@
#!/bin/bash
# LEDMatrix System Compatibility Checker
# Verifies system compatibility with LEDMatrix project
# Tests for Raspbian OS version, Python version, and required packages
set -Eeuo pipefail
echo "=========================================="
echo "LEDMatrix System Compatibility Checker"
echo "=========================================="
echo ""
# Color codes for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Track overall status
COMPATIBILITY_ISSUES=0
WARNINGS=0
# Helper functions
print_success() {
echo -e "${GREEN}${NC} $1"
}
print_warning() {
echo -e "${YELLOW}${NC} $1"
((WARNINGS++))
}
print_error() {
echo -e "${RED}${NC} $1"
((COMPATIBILITY_ISSUES++))
}
# Check if running on Raspberry Pi
echo "1. Checking Raspberry Pi Hardware..."
echo "-----------------------------------"
if [ -r /proc/device-tree/model ]; then
DEVICE_MODEL=$(tr -d '\0' </proc/device-tree/model)
echo "Detected device: $DEVICE_MODEL"
if [[ "$DEVICE_MODEL" == *"Raspberry Pi"* ]]; then
print_success "Running on Raspberry Pi hardware"
else
print_warning "Not running on Raspberry Pi hardware - LED matrix functionality will not work"
fi
else
print_warning "Could not detect device model - ensure this is a Raspberry Pi"
fi
echo ""
# Check OS version
echo "2. Checking Operating System Version..."
echo "---------------------------------------"
if [ -f /etc/os-release ]; then
. /etc/os-release
echo "OS: $PRETTY_NAME"
echo "Version ID: ${VERSION_ID:-unknown}"
if [[ "$ID" == "raspbian" ]] || [[ "$ID" == "debian" ]]; then
if [ "${VERSION_ID:-0}" -ge "12" ]; then
print_success "Running compatible Debian/Raspbian version (${VERSION_ID})"
if [ "${VERSION_ID:-0}" -eq "13" ]; then
print_success "Detected Debian 13 Trixie - full compatibility expected"
elif [ "${VERSION_ID:-0}" -eq "12" ]; then
print_success "Detected Debian 12 Bookworm - full compatibility confirmed"
fi
else
print_warning "Old Debian/Raspbian version (${VERSION_ID}) - upgrade recommended"
fi
else
print_warning "Not running Debian/Raspbian - compatibility not guaranteed"
fi
else
print_error "Could not detect OS version"
fi
echo ""
# Check kernel version
echo "3. Checking Kernel Version..."
echo "-----------------------------"
KERNEL_VERSION=$(uname -r)
KERNEL_MAJOR=$(echo "$KERNEL_VERSION" | cut -d. -f1)
KERNEL_MINOR=$(echo "$KERNEL_VERSION" | cut -d. -f2)
echo "Kernel: $KERNEL_VERSION"
if [ "$KERNEL_MAJOR" -ge "6" ]; then
print_success "Kernel version is compatible (6.x or newer)"
if [ "$KERNEL_MAJOR" -eq "6" ] && [ "$KERNEL_MINOR" -ge "12" ]; then
print_success "Running latest Trixie kernel (6.12 LTS)"
fi
elif [ "$KERNEL_MAJOR" -eq "5" ] && [ "$KERNEL_MINOR" -ge "10" ]; then
print_success "Kernel version is compatible (5.10+)"
else
print_warning "Kernel version may be too old - upgrade recommended"
fi
echo ""
# Check Python version
echo "4. Checking Python Version..."
echo "-----------------------------"
if command -v python3 >/dev/null 2>&1; then
PYTHON_VERSION=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")')
PYTHON_MAJOR=$(python3 -c 'import sys; print(sys.version_info.major)')
PYTHON_MINOR=$(python3 -c 'import sys; print(sys.version_info.minor)')
echo "Python: $PYTHON_VERSION"
if [ "$PYTHON_MAJOR" -eq "3" ]; then
if [ "$PYTHON_MINOR" -ge "10" ] && [ "$PYTHON_MINOR" -le "12" ]; then
print_success "Python version is fully supported (3.10-3.12)"
elif [ "$PYTHON_MINOR" -eq "13" ]; then
print_warning "Python 3.13 detected - most packages compatible, but some may have limited testing"
print_warning "Please report any compatibility issues you encounter"
elif [ "$PYTHON_MINOR" -ge "14" ]; then
print_warning "Python 3.${PYTHON_MINOR} is very new - some packages may not be compatible yet"
else
print_warning "Python 3.${PYTHON_MINOR} is outdated - upgrade to 3.10+ recommended"
fi
else
print_error "Python 2.x detected - Python 3.10+ is required"
fi
else
print_error "Python 3 not found - installation required"
fi
echo ""
# Check pip availability
echo "5. Checking pip..."
echo "-----------------"
if python3 -m pip --version >/dev/null 2>&1; then
PIP_VERSION=$(python3 -m pip --version | awk '{print $2}')
print_success "pip is available (version $PIP_VERSION)"
else
print_error "pip not found - python3-pip installation required"
fi
echo ""
# Check essential system packages
echo "6. Checking Essential System Packages..."
echo "----------------------------------------"
# List of essential packages
ESSENTIAL_PACKAGES=(
"python3-dev:Python development headers"
"python3-pil:Python Imaging Library"
"build-essential:Build tools"
"git:Version control"
)
for pkg_info in "${ESSENTIAL_PACKAGES[@]}"; do
IFS=':' read -r pkg desc <<< "$pkg_info"
if dpkg -l | grep -q "^ii $pkg "; then
print_success "$desc ($pkg) is installed"
else
print_warning "$desc ($pkg) not installed - will be installed during setup"
fi
done
echo ""
# Check for conflicting services
echo "7. Checking for Conflicting Services..."
echo "---------------------------------------"
# Check for services that can interfere with LED matrix
CONFLICTING_SERVICES=(
"bluetooth:Bluetooth service"
"bluez:Bluetooth stack"
)
for svc_info in "${CONFLICTING_SERVICES[@]}"; do
IFS=':' read -r svc desc <<< "$svc_info"
if systemctl is-active --quiet "$svc" 2>/dev/null; then
print_warning "$desc ($svc) is running - may cause LED matrix timing issues"
else
print_success "$desc ($svc) is not running"
fi
done
echo ""
# Check boot configuration
echo "8. Checking Boot Configuration..."
echo "---------------------------------"
# Check for cmdline.txt location
CMDLINE_FILE=""
if [ -f "/boot/firmware/cmdline.txt" ]; then
CMDLINE_FILE="/boot/firmware/cmdline.txt"
elif [ -f "/boot/cmdline.txt" ]; then
CMDLINE_FILE="/boot/cmdline.txt"
fi
if [ -n "$CMDLINE_FILE" ]; then
print_success "Boot configuration found: $CMDLINE_FILE"
if grep -q '\bisolcpus=3\b' "$CMDLINE_FILE"; then
print_success "CPU isolation already configured (isolcpus=3)"
else
print_warning "CPU isolation not configured - will be set during installation"
fi
else
print_warning "Boot configuration file not found"
fi
# Check config.txt location
CONFIG_FILE=""
if [ -f "/boot/firmware/config.txt" ]; then
CONFIG_FILE="/boot/firmware/config.txt"
elif [ -f "/boot/config.txt" ]; then
CONFIG_FILE="/boot/config.txt"
fi
if [ -n "$CONFIG_FILE" ]; then
if grep -q '^dtparam=audio=off' "$CONFIG_FILE"; then
print_success "Onboard audio already disabled"
else
print_warning "Onboard audio not disabled - will be configured during installation"
fi
fi
echo ""
# Check available memory
echo "9. Checking System Resources..."
echo "-------------------------------"
if command -v free >/dev/null 2>&1; then
TOTAL_MEM=$(free -m | awk '/^Mem:/{print $2}')
echo "Total RAM: ${TOTAL_MEM}MB"
if [ "$TOTAL_MEM" -ge "1024" ]; then
print_success "Sufficient memory available"
elif [ "$TOTAL_MEM" -ge "512" ]; then
print_warning "Limited memory (${TOTAL_MEM}MB) - may affect performance"
else
print_warning "Very limited memory (${TOTAL_MEM}MB) - performance issues likely"
fi
fi
# Check disk space
if command -v df >/dev/null 2>&1; then
AVAILABLE_SPACE=$(df -m / | awk 'NR==2{print $4}')
echo "Available disk space: ${AVAILABLE_SPACE}MB"
if [ "$AVAILABLE_SPACE" -ge "1024" ]; then
print_success "Sufficient disk space available"
elif [ "$AVAILABLE_SPACE" -ge "512" ]; then
print_warning "Limited disk space (${AVAILABLE_SPACE}MB) - may need cleanup"
else
print_error "Very limited disk space (${AVAILABLE_SPACE}MB) - cleanup required"
fi
fi
echo ""
# Check network connectivity
echo "10. Checking Network Connectivity..."
echo "------------------------------------"
if command -v ping >/dev/null 2>&1; then
if ping -c 1 -W 3 8.8.8.8 >/dev/null 2>&1; then
print_success "Internet connectivity available"
else
print_error "No internet connectivity - required for installation"
fi
else
print_warning "Ping command not available - cannot verify network"
fi
echo ""
# Print summary
echo "=========================================="
echo "Compatibility Check Summary"
echo "=========================================="
echo ""
if [ $COMPATIBILITY_ISSUES -eq 0 ] && [ $WARNINGS -eq 0 ]; then
echo -e "${GREEN}✓ System is fully compatible!${NC}"
echo "You can proceed with the installation."
exit 0
elif [ $COMPATIBILITY_ISSUES -eq 0 ]; then
echo -e "${YELLOW}⚠ System is compatible with ${WARNINGS} warning(s)${NC}"
echo "Installation can proceed, but review the warnings above."
exit 0
else
echo -e "${RED}✗ Found ${COMPATIBILITY_ISSUES} compatibility issue(s) and ${WARNINGS} warning(s)${NC}"
echo "Please address the errors above before installation."
exit 1
fi

View File

@@ -0,0 +1,29 @@
#!/bin/bash
# Clear all plugin dependency markers to force fresh dependency check
# Useful after updating plugins or troubleshooting dependency issues
echo "Clearing plugin dependency markers..."
# Check both possible cache locations
CACHE_DIRS=(
"/var/cache/ledmatrix"
"$HOME/.cache/ledmatrix"
)
for CACHE_DIR in "${CACHE_DIRS[@]}"; do
if [ -d "$CACHE_DIR" ]; then
echo "Checking $CACHE_DIR..."
marker_count=$(find "$CACHE_DIR" -name "plugin_*_deps_installed" 2>/dev/null | wc -l)
if [ "$marker_count" -gt 0 ]; then
echo "Found $marker_count dependency marker(s) in $CACHE_DIR"
find "$CACHE_DIR" -name "plugin_*_deps_installed" -delete
echo "Cleared $marker_count marker(s)"
else
echo "No dependency markers found in $CACHE_DIR"
fi
fi
done
echo "Done! Dependency markers cleared."
echo "Next startup will check and install dependencies as needed."

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env python3
"""
Check what imports are actually in the app.py file on the Pi
"""
import sys
import os
from pathlib import Path
# Read the app.py file and check the import lines
app_py_path = Path.home() / 'LEDMatrix' / 'web_interface' / 'app.py'
print(f"🔍 Checking imports in: {app_py_path}")
print(f"📁 File exists: {app_py_path.exists()}")
if app_py_path.exists():
with open(app_py_path, 'r') as f:
lines = f.readlines()
print("\n🔍 Import lines in app.py:")
for i, line in enumerate(lines, 1):
if 'from' in line and 'blueprints' in line and 'import' in line:
print(f" Line {i}: {line.strip()}")
print("\n🔍 Blueprint registration lines:")
for i, line in enumerate(lines, 1):
if 'register_blueprint' in line:
print(f" Line {i}: {line.strip()}")
else:
print("❌ app.py file not found!")

View File

@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""
Web Interface Manual Debug Script
Run this to diagnose why web_interface/start.py isn't working
"""
import sys
import os
import traceback
from pathlib import Path
def main():
print("🔍 LED Matrix Web Interface Debug Tool")
print("=" * 50)
# Change to project root (where this script is located)
project_root = Path(__file__).parent.resolve()
os.chdir(project_root)
print(f"📁 Working directory: {os.getcwd()}")
# Add to Python path
sys.path.insert(0, str(project_root))
print(f"🔗 Python path includes: {project_root}")
print("\n1. Testing basic imports...")
try:
import flask
print(f" ✅ Flask: {flask.__version__}")
except ImportError as e:
print(f" ❌ Flask missing: {e}")
return False
try:
from src.config_manager import ConfigManager
print(" ✅ ConfigManager imported")
except Exception as e:
print(f" ❌ ConfigManager failed: {e}")
traceback.print_exc()
return False
print("\n2. Testing web interface imports...")
try:
from web_interface.app import app
print(" ✅ web_interface.app imported")
print(f" 📋 App object: {app}")
except Exception as e:
print(f" ❌ web_interface.app failed: {e}")
traceback.print_exc()
return False
print("\n3. Checking config...")
try:
config_manager = ConfigManager()
config = config_manager.load_config()
print(" ✅ Config loaded")
autostart = config.get('web_display_autostart', False)
print(f" 🔧 web_display_autostart: {autostart}")
except Exception as e:
print(f" ❌ Config check failed: {e}")
traceback.print_exc()
return False
print("\n4. Testing Flask startup...")
try:
print(" 🚀 Starting Flask app...")
print(" 📍 Will run on: http://0.0.0.0:5000")
print(" ⏹️ Press Ctrl+C to stop")
# Run the app (this should start the server)
app.run(host='0.0.0.0', port=5000, debug=True)
except KeyboardInterrupt:
print("\n ⏹️ Server stopped by user")
return True
except Exception as e:
print(f" ❌ Flask startup failed: {e}")
traceback.print_exc()
return False
if __name__ == "__main__":
try:
success = main()
if success:
print("\n✅ Debug completed successfully")
else:
print("\n❌ Debug found issues - check output above")
except Exception as e:
print(f"\n💥 Debug script crashed: {e}")
traceback.print_exc()

View File

@@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""
Direct fix for import issues - manually edit the app.py file
"""
import os
from pathlib import Path
def fix_imports():
app_py_path = Path.home() / 'LEDMatrix' / 'web_interface' / 'app.py'
print(f"🔧 Directly fixing imports in: {app_py_path}")
# Read the file
with open(app_py_path, 'r') as f:
lines = f.readlines()
# Find and fix the import lines
fixed = False
for i, line in enumerate(lines, 1):
if 'from blueprints.pages_v3 import' in line:
lines[i-1] = "from web_interface.blueprints.pages_v3 import pages_v3\n"
print(f"✅ Fixed line {i}: from blueprints.pages_v3 import → from web_interface.blueprints.pages_v3 import")
fixed = True
elif 'from blueprints.api_v3 import' in line:
lines[i-1] = "from web_interface.blueprints.api_v3 import api_v3\n"
print(f"✅ Fixed line {i}: from blueprints.api_v3 import → from web_interface.blueprints.api_v3 import")
fixed = True
if not fixed:
print("❌ No import lines found to fix")
return False
# Write the fixed file back
with open(app_py_path, 'w') as f:
f.writelines(lines)
print("✅ File updated successfully")
return True
def verify_fix():
print("\n🔍 Verifying the fix...")
os.system("python3 check_imports.py")
if __name__ == "__main__":
if fix_imports():
print("\n🧹 Clearing Python cache...")
os.system("find ~/LEDMatrix -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true")
os.system("find ~/LEDMatrix -name '*.pyc' -delete 2>/dev/null || true")
print("\n✅ Imports fixed and cache cleared!")
verify_fix()
print("\n🚀 Now try running the web interface:")
print("cd ~/LEDMatrix")
print("python3 web_interface/start.py")
else:
print("\n❌ Fix failed")

27
scripts/dev/README.md Normal file
View File

@@ -0,0 +1,27 @@
# Development Scripts
This directory contains scripts and utilities for development and testing.
## Scripts
- **`dev_plugin_setup.sh`** - Sets up plugin development environment by linking plugin repositories
- **`run_emulator.sh`** - Runs the LED Matrix display in emulator mode (for development without hardware)
- **`validate_python.py`** - Validates Python files for common formatting and syntax errors
## Usage
### Plugin Development Setup
```bash
./scripts/dev/dev_plugin_setup.sh link-github <plugin-name>
```
### Running Emulator
```bash
./scripts/dev/run_emulator.sh
```
### Validating Python Files
```bash
python3 scripts/dev/validate_python.py <file.py>
```

525
scripts/dev/dev_plugin_setup.sh Executable file
View File

@@ -0,0 +1,525 @@
#!/bin/bash
# LEDMatrix Plugin Development Setup Script
# Manages symbolic links between plugin repositories and the plugins/ directory
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$SCRIPT_DIR"
PLUGINS_DIR="$PROJECT_ROOT/plugins"
CONFIG_FILE="$PROJECT_ROOT/dev_plugins.json"
DEFAULT_DEV_DIR="$HOME/.ledmatrix-dev-plugins"
GITHUB_USER="ChuckBuilds"
GITHUB_PATTERN="ledmatrix-"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Logging functions
log_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
log_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Load configuration file
load_config() {
if [[ -f "$CONFIG_FILE" ]]; then
DEV_PLUGINS_DIR=$(jq -r '.dev_plugins_dir // "'"$DEFAULT_DEV_DIR"'"' "$CONFIG_FILE" 2>/dev/null || echo "$DEFAULT_DEV_DIR")
# Expand ~ in path
DEV_PLUGINS_DIR="${DEV_PLUGINS_DIR/#\~/$HOME}"
else
DEV_PLUGINS_DIR="$DEFAULT_DEV_DIR"
fi
mkdir -p "$DEV_PLUGINS_DIR"
}
# Validate plugin structure
validate_plugin() {
local plugin_path="$1"
if [[ ! -f "$plugin_path/manifest.json" ]]; then
log_error "Plugin directory does not contain manifest.json: $plugin_path"
return 1
fi
return 0
}
# Get plugin ID from manifest
get_plugin_id() {
local plugin_path="$1"
if [[ -f "$plugin_path/manifest.json" ]]; then
jq -r '.id // empty' "$plugin_path/manifest.json" 2>/dev/null || echo ""
fi
}
# Check if path is a symlink
is_symlink() {
[[ -L "$1" ]]
}
# Check if plugin directory exists
plugin_exists() {
[[ -e "$PLUGINS_DIR/$1" ]]
}
# Get symlink target
get_symlink_target() {
if is_symlink "$PLUGINS_DIR/$1"; then
readlink -f "$PLUGINS_DIR/$1"
else
echo ""
fi
}
# Link a local plugin repository
link_plugin() {
local plugin_name="$1"
local repo_path="$2"
if [[ -z "$plugin_name" ]] || [[ -z "$repo_path" ]]; then
log_error "Usage: $0 link <plugin-name> <repo-path>"
exit 1
fi
# Resolve absolute path
if [[ ! "$repo_path" = /* ]]; then
repo_path="$(cd "$(dirname "$repo_path")" && pwd)/$(basename "$repo_path")"
fi
if [[ ! -d "$repo_path" ]]; then
log_error "Repository path does not exist: $repo_path"
exit 1
fi
# Validate plugin structure
if ! validate_plugin "$repo_path"; then
exit 1
fi
# Check for existing plugin
if plugin_exists "$plugin_name"; then
if is_symlink "$PLUGINS_DIR/$plugin_name"; then
local target=$(get_symlink_target "$plugin_name")
if [[ "$target" == "$repo_path" ]]; then
log_info "Plugin $plugin_name is already linked to $repo_path"
return 0
else
log_warn "Plugin $plugin_name exists as symlink to $target"
read -p "Replace existing symlink? (y/N): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
log_info "Aborted"
exit 0
fi
rm "$PLUGINS_DIR/$plugin_name"
fi
else
log_warn "Plugin directory exists but is not a symlink: $PLUGINS_DIR/$plugin_name"
read -p "Backup and replace? (y/N): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
log_info "Aborted"
exit 0
fi
mv "$PLUGINS_DIR/$plugin_name" "$PLUGINS_DIR/${plugin_name}.backup.$(date +%Y%m%d%H%M%S)"
fi
fi
# Create symlink
ln -s "$repo_path" "$PLUGINS_DIR/$plugin_name"
local plugin_id=$(get_plugin_id "$repo_path")
if [[ -n "$plugin_id" ]] && [[ "$plugin_id" != "$plugin_name" ]]; then
log_warn "Plugin ID in manifest ($plugin_id) differs from directory name ($plugin_name)"
fi
log_success "Linked $plugin_name to $repo_path"
}
# Clone repository from GitHub
clone_from_github() {
local repo_url="$1"
local target_dir="$2"
local branch="${3:-}"
log_info "Cloning $repo_url to $target_dir"
local clone_cmd=("git" "clone")
if [[ -n "$branch" ]]; then
clone_cmd+=("--branch" "$branch")
fi
clone_cmd+=("--depth" "1" "$repo_url" "$target_dir")
if ! "${clone_cmd[@]}"; then
log_error "Failed to clone repository"
return 1
fi
log_success "Cloned repository successfully"
return 0
}
# Link plugin from GitHub
link_github_plugin() {
local plugin_name="$1"
local repo_url="${2:-}"
if [[ -z "$plugin_name" ]]; then
log_error "Usage: $0 link-github <plugin-name> [repo-url]"
exit 1
fi
load_config
# Construct repo URL if not provided
if [[ -z "$repo_url" ]]; then
repo_url="https://github.com/${GITHUB_USER}/${GITHUB_PATTERN}${plugin_name}.git"
log_info "Using default GitHub URL: $repo_url"
fi
# Determine target directory name from URL
local repo_name=$(basename "$repo_url" .git)
local target_dir="$DEV_PLUGINS_DIR/$repo_name"
# Check if already cloned
if [[ -d "$target_dir" ]]; then
log_info "Repository already exists at $target_dir"
if [[ -d "$target_dir/.git" ]]; then
log_info "Updating repository..."
(cd "$target_dir" && git pull --rebase || true)
fi
else
# Clone the repository
if ! clone_from_github "$repo_url" "$target_dir"; then
exit 1
fi
fi
# Validate plugin structure
if ! validate_plugin "$target_dir"; then
log_error "Cloned repository does not appear to be a valid plugin"
exit 1
fi
# Link the plugin
link_plugin "$plugin_name" "$target_dir"
}
# Unlink a plugin
unlink_plugin() {
local plugin_name="$1"
if [[ -z "$plugin_name" ]]; then
log_error "Usage: $0 unlink <plugin-name>"
exit 1
fi
if ! plugin_exists "$plugin_name"; then
log_error "Plugin does not exist: $plugin_name"
exit 1
fi
if ! is_symlink "$PLUGINS_DIR/$plugin_name"; then
log_warn "Plugin $plugin_name is not a symlink. Cannot unlink."
exit 1
fi
local target=$(get_symlink_target "$plugin_name")
rm "$PLUGINS_DIR/$plugin_name"
log_success "Unlinked $plugin_name (repository preserved at $target)"
}
# List all plugins
list_plugins() {
if [[ ! -d "$PLUGINS_DIR" ]]; then
log_error "Plugins directory does not exist: $PLUGINS_DIR"
exit 1
fi
echo -e "${BLUE}Plugin Status:${NC}"
echo "==============="
echo
local has_plugins=false
for item in "$PLUGINS_DIR"/*; do
[[ -e "$item" ]] || continue
[[ -d "$item" ]] || continue
local plugin_name=$(basename "$item")
[[ "$plugin_name" =~ ^\.|^_ ]] && continue
has_plugins=true
if is_symlink "$item"; then
local target=$(get_symlink_target "$plugin_name")
echo -e "${GREEN}${NC} ${BLUE}$plugin_name${NC} (symlink)"
echo "$target"
# Check git status if it's a git repo
if [[ -d "$target/.git" ]]; then
local branch=$(cd "$target" && git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
local status=$(cd "$target" && git status --porcelain 2>/dev/null | head -1)
if [[ -n "$status" ]]; then
echo -e " ${YELLOW}⚠ Git repo has uncommitted changes${NC} (branch: $branch)"
else
echo -e " ${GREEN}✓ Git repo is clean${NC} (branch: $branch)"
fi
fi
else
echo -e "${YELLOW}${NC} ${BLUE}$plugin_name${NC} (regular directory)"
fi
echo
done
if [[ "$has_plugins" == false ]]; then
log_info "No plugins found in $PLUGINS_DIR"
fi
}
# Check status of all linked plugins
check_status() {
if [[ ! -d "$PLUGINS_DIR" ]]; then
log_error "Plugins directory does not exist: $PLUGINS_DIR"
exit 1
fi
echo -e "${BLUE}Plugin Development Status:${NC}"
echo "========================="
echo
local broken_count=0
local clean_count=0
local dirty_count=0
for item in "$PLUGINS_DIR"/*; do
[[ -e "$item" ]] || continue
[[ -d "$item" ]] || continue
local plugin_name=$(basename "$item")
[[ "$plugin_name" =~ ^\.|^_ ]] && continue
if is_symlink "$item"; then
if [[ ! -e "$item" ]]; then
echo -e "${RED}${NC} ${BLUE}$plugin_name${NC} - ${RED}BROKEN SYMLINK${NC}"
broken_count=$((broken_count + 1))
continue
fi
local target=$(get_symlink_target "$plugin_name")
echo -e "${GREEN}${NC} ${BLUE}$plugin_name${NC}"
echo " Path: $target"
if [[ -d "$target/.git" ]]; then
local branch=$(cd "$target" && git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
local remote=$(cd "$target" && git remote get-url origin 2>/dev/null || echo "no remote")
local commits_behind=$(cd "$target" && git rev-list --count HEAD..@{upstream} 2>/dev/null || echo "0")
local commits_ahead=$(cd "$target" && git rev-list --count @{upstream}..HEAD 2>/dev/null || echo "0")
local status=$(cd "$target" && git status --porcelain 2>/dev/null)
echo " Branch: $branch"
echo " Remote: $remote"
if [[ -n "$status" ]]; then
echo -e " ${YELLOW}Status: Has uncommitted changes${NC}"
dirty_count=$((dirty_count + 1))
elif [[ "$commits_behind" != "0" ]] || [[ "$commits_ahead" != "0" ]]; then
if [[ "$commits_behind" != "0" ]]; then
echo -e " ${YELLOW}Status: $commits_behind commit(s) behind remote${NC}"
fi
if [[ "$commits_ahead" != "0" ]]; then
echo -e " ${GREEN}Status: $commits_ahead commit(s) ahead of remote${NC}"
fi
dirty_count=$((dirty_count + 1))
else
echo -e " ${GREEN}Status: Clean and up to date${NC}"
clean_count=$((clean_count + 1))
fi
else
echo " (Not a git repository)"
fi
echo
fi
done
echo "Summary:"
echo " ${GREEN}Clean: $clean_count${NC}"
echo " ${YELLOW}Needs attention: $dirty_count${NC}"
[[ $broken_count -gt 0 ]] && echo -e " ${RED}Broken: $broken_count${NC}"
}
# Update plugin(s)
update_plugins() {
local plugin_name="${1:-}"
load_config
if [[ -n "$plugin_name" ]]; then
# Update single plugin
if ! plugin_exists "$plugin_name"; then
log_error "Plugin does not exist: $plugin_name"
exit 1
fi
if ! is_symlink "$PLUGINS_DIR/$plugin_name"; then
log_error "Plugin $plugin_name is not a symlink"
exit 1
fi
local target=$(get_symlink_target "$plugin_name")
if [[ ! -d "$target/.git" ]]; then
log_error "Plugin repository is not a git repository: $target"
exit 1
fi
log_info "Updating $plugin_name from $target"
(cd "$target" && git pull --rebase)
log_success "Updated $plugin_name"
else
# Update all linked plugins
log_info "Updating all linked plugins..."
local updated=0
local failed=0
for item in "$PLUGINS_DIR"/*; do
[[ -e "$item" ]] || continue
[[ -d "$item" ]] || continue
local name=$(basename "$item")
[[ "$name" =~ ^\.|^_ ]] && continue
if is_symlink "$item"; then
local target=$(get_symlink_target "$name")
if [[ -d "$target/.git" ]]; then
log_info "Updating $name..."
if (cd "$target" && git pull --rebase); then
log_success "Updated $name"
updated=$((updated + 1))
else
log_error "Failed to update $name"
failed=$((failed + 1))
fi
fi
fi
done
echo
log_info "Update complete: $updated succeeded, $failed failed"
fi
}
# Show usage
show_usage() {
cat << EOF
LEDMatrix Plugin Development Setup
Usage: $0 <command> [options]
Commands:
link <plugin-name> <repo-path>
Link a local plugin repository to the plugins directory
link-github <plugin-name> [repo-url]
Clone and link a plugin from GitHub
If repo-url is not provided, uses: https://github.com/${GITHUB_USER}/${GITHUB_PATTERN}<plugin-name>.git
unlink <plugin-name>
Remove symlink for a plugin (preserves repository)
list
List all plugins and their link status
status
Check status of all linked plugins (git status, branch, etc.)
update [plugin-name]
Update plugin(s) from git repository
If plugin-name is omitted, updates all linked plugins
help
Show this help message
Examples:
# Link a local plugin
$0 link music ../ledmatrix-music
# Link from GitHub (auto-detects URL)
$0 link-github music
# Link from GitHub with custom URL
$0 link-github stocks https://github.com/ChuckBuilds/ledmatrix-stocks.git
# Check status
$0 status
# Update all plugins
$0 update
Configuration:
Create dev_plugins.json in project root to customize:
- dev_plugins_dir: Where to clone GitHub repos (default: ~/.ledmatrix-dev-plugins)
- plugins: Plugin definitions (optional, for auto-discovery)
EOF
}
# Main command dispatcher
main() {
# Ensure plugins directory exists
mkdir -p "$PLUGINS_DIR"
case "${1:-}" in
link)
shift
link_plugin "$@"
;;
link-github)
shift
link_github_plugin "$@"
;;
unlink)
shift
unlink_plugin "$@"
;;
list)
list_plugins
;;
status)
check_status
;;
update)
shift
update_plugins "$@"
;;
help|--help|-h|"")
show_usage
;;
*)
log_error "Unknown command: $1"
echo
show_usage
exit 1
;;
esac
}
main "$@"

View File

@@ -0,0 +1 @@
/home/chuck/.ledmatrix-dev-plugins/ledmatrix-of-the-day

13
scripts/dev/run_emulator.sh Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/bash
# LEDMatrix Emulator Runner
# This script runs the LEDMatrix system in emulator mode for development and testing
echo "Starting LEDMatrix Emulator..."
echo "Press Ctrl+C to stop"
echo ""
# Set emulator mode
export EMULATOR=true
# Run the main application
python3 run.py

View File

@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""
Python file validation script to prevent common formatting errors.
This script checks for:
1. Proper indentation (4 spaces, no mixed tabs/spaces)
2. Missing imports
3. Syntax errors
4. Line length issues
5. Proper try/except structure
Usage: python tools/validate_python.py <python_file>
"""
import ast
import sys
import os
from pathlib import Path
def validate_file(filepath: str) -> bool:
"""Validate a Python file for common issues."""
try:
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
issues_found = []
# Check for tabs (should use spaces)
if '\t' in content:
issues_found.append("❌ Contains tabs - use 4 spaces instead")
# Check for trailing whitespace
lines = content.split('\n')
for i, line in enumerate(lines, 1):
if line.rstrip() != line:
issues_found.append(f"❌ Line {i}: Trailing whitespace")
# Check for very long lines
for i, line in enumerate(lines, 1):
if len(line) > 120:
issues_found.append(f"⚠️ Line {i}: Very long line ({len(line)} chars)")
# Check for proper try/except structure
try:
ast.parse(content)
except SyntaxError as e:
issues_found.append(f"❌ Syntax error: {e}")
# Check for mixed quotes (inconsistency)
single_quotes = content.count("'")
double_quotes = content.count('"')
if single_quotes > 0 and double_quotes > 0:
issues_found.append("⚠️ Mixed quote usage - consider using double quotes consistently")
# Report results
if issues_found:
print(f"\n🔍 Validation Results for: {filepath}")
print("=" * 50)
for issue in issues_found:
print(issue)
return False
else:
print(f"{filepath} - All checks passed!")
return True
except Exception as e:
print(f"❌ Error reading file {filepath}: {e}")
return False
def validate_directory(directory: str) -> bool:
"""Validate all Python files in a directory."""
all_passed = True
for root, dirs, files in os.walk(directory):
for file in files:
if file.endswith('.py'):
filepath = os.path.join(root, file)
if not validate_file(filepath):
all_passed = False
return all_passed
if __name__ == "__main__":
if len(sys.argv) != 2:
print("Usage: python tools/validate_python.py <file_or_directory>")
sys.exit(1)
target = sys.argv[1]
if os.path.isfile(target):
validate_file(target)
elif os.path.isdir(target):
validate_directory(target)
else:
print(f"❌ Path not found: {target}")
sys.exit(1)

197
scripts/diagnose_dependencies.sh Executable file
View File

@@ -0,0 +1,197 @@
#!/bin/bash
# Diagnostic script for Python dependency installation issues
# Run this if pip gets stuck on "Preparing metadata (pyproject.toml)"
set -e
echo "=========================================="
echo "LEDMatrix Dependency Diagnostic Tool"
echo "=========================================="
echo ""
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Get project root (parent of scripts/ directory)
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
PROJECT_ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
echo "Project directory: $PROJECT_ROOT_DIR"
echo ""
# Check system resources
echo "=== System Resources ==="
echo "Disk space:"
df -h / | tail -1
echo ""
echo "Memory:"
free -h
echo ""
echo "CPU info:"
grep -E "^model name|^Hardware|^Revision" /proc/cpuinfo | head -3 || echo "CPU info not available"
echo ""
# Check Python and pip versions
echo "=== Python Environment ==="
echo "Python version:"
python3 --version
echo ""
echo "Pip version:"
python3 -m pip --version || echo "pip not available"
echo ""
# Check if timeout command is available
echo "=== Available Tools ==="
if command -v timeout >/dev/null 2>&1; then
echo -e "${GREEN}${NC} timeout command available"
else
echo -e "${YELLOW}${NC} timeout command not available (install with: sudo apt install coreutils)"
fi
if command -v apt >/dev/null 2>&1; then
echo -e "${GREEN}${NC} apt available"
else
echo -e "${RED}${NC} apt not available"
fi
echo ""
# Check installed build tools
echo "=== Build Tools ==="
BUILD_TOOLS=("gcc" "g++" "make" "python3-dev" "build-essential" "cython3")
for tool in "${BUILD_TOOLS[@]}"; do
if dpkg -l | grep -q "^ii.*$tool"; then
echo -e "${GREEN}${NC} $tool installed"
else
echo -e "${RED}${NC} $tool not installed"
fi
done
echo ""
# Check pip cache
echo "=== Pip Cache ==="
PIP_CACHE_DIR=$(python3 -m pip cache dir 2>/dev/null || echo "unknown")
echo "Pip cache directory: $PIP_CACHE_DIR"
if [ -d "$PIP_CACHE_DIR" ]; then
CACHE_SIZE=$(du -sh "$PIP_CACHE_DIR" 2>/dev/null | cut -f1 || echo "unknown")
echo "Cache size: $CACHE_SIZE"
echo "You can clear the cache with: python3 -m pip cache purge"
fi
echo ""
# Check requirements.txt
echo "=== Requirements File ==="
if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
echo -e "${GREEN}${NC} requirements.txt found"
TOTAL_PACKAGES=$(grep -v '^#' "$PROJECT_ROOT_DIR/requirements.txt" | grep -v '^$' | wc -l)
echo "Total packages: $TOTAL_PACKAGES"
echo ""
echo "Packages that may need building from source:"
grep -v '^#' "$PROJECT_ROOT_DIR/requirements.txt" | grep -v '^$' | grep -E "(numpy|freetype|cython|scipy|pandas)" || echo " (none detected)"
else
echo -e "${RED}${NC} requirements.txt not found at $PROJECT_ROOT_DIR/requirements.txt"
fi
echo ""
# Test installing a simple package
echo "=== Test Installation ==="
echo "Testing pip with a simple package (setuptools)..."
if python3 -m pip install --break-system-packages --upgrade --quiet setuptools >/dev/null 2>&1; then
echo -e "${GREEN}${NC} Pip is working correctly"
else
echo -e "${RED}${NC} Pip installation test failed"
echo "Try: python3 -m pip install --break-system-packages --upgrade pip setuptools wheel"
fi
echo ""
# Check for common issues
echo "=== Common Issues Check ==="
# Check if running as root
if [ "$EUID" -eq 0 ]; then
echo -e "${YELLON}${NC} Running as root - ensure --break-system-packages flag is used"
else
echo -e "${GREEN}${NC} Not running as root (good for user installs)"
fi
# Check network connectivity
if ping -c 1 -W 3 pypi.org >/dev/null 2>&1; then
echo -e "${GREEN}${NC} Network connectivity to PyPI OK"
else
echo -e "${RED}${NC} Cannot reach pypi.org - check network connection"
fi
# Check for proxy issues
if [ -n "${HTTP_PROXY:-}" ] || [ -n "${HTTPS_PROXY:-}" ]; then
echo -e "${BLUE}${NC} Proxy configured: HTTP_PROXY=${HTTP_PROXY:-none}, HTTPS_PROXY=${HTTPS_PROXY:-none}"
else
echo -e "${GREEN}${NC} No proxy configured"
fi
echo ""
# Recommendations
echo "=== Recommendations ==="
echo ""
echo "If pip gets stuck on 'Preparing metadata (pyproject.toml)':"
echo ""
echo "1. Install/upgrade build tools:"
echo " sudo apt update && sudo apt install -y build-essential python3-dev python3-pip python3-setuptools python3-wheel cython3"
echo ""
echo "2. Upgrade pip and build tools:"
echo " python3 -m pip install --break-system-packages --upgrade pip setuptools wheel"
echo ""
echo "3. Try installing packages one at a time with verbose output:"
echo " python3 -m pip install --break-system-packages --no-cache-dir --verbose <package-name>"
echo ""
echo "4. For packages that build from source (like numpy), try:"
echo " - Install pre-built wheels: python3 -m pip install --break-system-packages --only-binary :all: <package>"
echo " - Or install via apt if available: sudo apt install python3-<package>"
echo ""
echo "5. Clear pip cache if corrupted:"
echo " python3 -m pip cache purge"
echo ""
echo "6. Check disk space - building packages requires temporary space"
echo " df -h"
echo ""
echo "7. For slow builds, increase swap space:"
echo " sudo dphys-swapfile swapoff"
echo " sudo nano /etc/dphys-swapfile # Set CONF_SWAPSIZE=2048"
echo " sudo dphys-swapfile setup"
echo " sudo dphys-swapfile swapon"
echo ""
echo "8. Install packages with timeout to identify problematic ones:"
echo " timeout 600 python3 -m pip install --break-system-packages --no-cache-dir --verbose <package>"
echo ""
# Check which packages are already installed
echo "=== Currently Installed Packages ==="
echo "Checking which requirements are already satisfied..."
if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
while IFS= read -r line || [ -n "$line" ]; do
line=$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
if [[ "$line" =~ ^#.*$ ]] || [[ -z "$line" ]]; then
continue
fi
PACKAGE_NAME=$(echo "$line" | sed -E 's/[<>=!].*$//' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | tr '[:upper:]' '[:lower:]')
# Try importing the package (basic check)
if python3 -c "import $PACKAGE_NAME" >/dev/null 2>&1; then
INSTALLED_VERSION=$(python3 -c "import $PACKAGE_NAME; print(getattr($PACKAGE_NAME, '__version__', 'unknown'))" 2>/dev/null || echo "unknown")
echo -e "${GREEN}${NC} $PACKAGE_NAME ($INSTALLED_VERSION)"
else
echo -e "${RED}${NC} $PACKAGE_NAME (not installed or import failed)"
fi
done < "$PROJECT_ROOT_DIR/requirements.txt" | head -20
echo ""
echo "(Showing first 20 packages - run full check with: python3 -m pip check)"
fi
echo ""
echo "=========================================="
echo "Diagnostic complete!"
echo "=========================================="

View File

@@ -0,0 +1,172 @@
#!/bin/bash
# Diagnostic script for plugin directory permissions
# Run this on the Raspberry Pi to check and fix plugin directory permissions
set -e
echo "=========================================="
echo "Plugin Directory Permissions Diagnostic"
echo "=========================================="
echo ""
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Get the actual user
if [ -n "$SUDO_USER" ]; then
ACTUAL_USER="$SUDO_USER"
else
ACTUAL_USER=$(whoami)
fi
# Get project root (parent of scripts/ directory)
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
PROJECT_ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
PLUGINS_DIR="$PROJECT_ROOT_DIR/plugins"
PLUGIN_REPOS_DIR="$PROJECT_ROOT_DIR/plugin-repos"
echo "Project root: $PROJECT_ROOT_DIR"
echo "User: $ACTUAL_USER"
echo "Running as: $(whoami)"
echo ""
# Check parent directory permissions
echo "=== Parent Directory Permissions ==="
echo "Checking /home/$ACTUAL_USER:"
if [ -d "/home/$ACTUAL_USER" ]; then
PERMS=$(stat -c "%a %U:%G" "/home/$ACTUAL_USER")
echo " Permissions: $PERMS"
if [ "$(stat -c "%a" "/home/$ACTUAL_USER")" != "755" ] && [ "$(stat -c "%a" "/home/$ACTUAL_USER")" != "750" ]; then
echo -e " ${YELLOW}⚠ Warning: Home directory has restrictive permissions${NC}"
echo " Root may not be able to access subdirectories"
fi
else
echo -e " ${RED}✗ Home directory not found${NC}"
fi
echo ""
echo "Checking $PROJECT_ROOT_DIR:"
if [ -d "$PROJECT_ROOT_DIR" ]; then
PERMS=$(stat -c "%a %U:%G" "$PROJECT_ROOT_DIR")
echo " Permissions: $PERMS"
if [ "$(stat -c "%a" "$PROJECT_ROOT_DIR")" != "755" ] && [ "$(stat -c "%a" "$PROJECT_ROOT_DIR")" != "750" ]; then
echo -e " ${YELLOW}⚠ Warning: Project directory has restrictive permissions${NC}"
fi
else
echo -e " ${RED}✗ Project directory not found${NC}"
fi
echo ""
# Check plugins directory
echo "=== Plugins Directory ==="
if [ -d "$PLUGINS_DIR" ]; then
PERMS=$(stat -c "%a %U:%G" "$PLUGINS_DIR")
echo " Path: $PLUGINS_DIR"
echo " Permissions: $PERMS"
OWNER=$(stat -c "%U" "$PLUGINS_DIR")
GROUP=$(stat -c "%G" "$PLUGINS_DIR")
PERM_BITS=$(stat -c "%a" "$PLUGINS_DIR")
if [ "$OWNER" = "root" ] && [ "$GROUP" = "$ACTUAL_USER" ] && [ "$PERM_BITS" = "775" ]; then
echo -e " ${GREEN}✓ Correct permissions${NC}"
else
echo -e " ${RED}✗ Incorrect permissions${NC}"
echo " Expected: root:$ACTUAL_USER 775"
echo " Actual: $OWNER:$GROUP $PERM_BITS"
fi
# Check if root can access
if sudo -u root test -r "$PLUGINS_DIR" && sudo -u root test -w "$PLUGINS_DIR"; then
echo -e " ${GREEN}✓ Root can read/write${NC}"
else
echo -e " ${RED}✗ Root cannot access${NC}"
fi
else
echo -e " ${YELLOW}⚠ Directory does not exist${NC}"
fi
echo ""
# Check plugin-repos directory
echo "=== Plugin-Repos Directory ==="
if [ -d "$PLUGIN_REPOS_DIR" ]; then
PERMS=$(stat -c "%a %U:%G" "$PLUGIN_REPOS_DIR")
echo " Path: $PLUGIN_REPOS_DIR"
echo " Permissions: $PERMS"
OWNER=$(stat -c "%U" "$PLUGIN_REPOS_DIR")
GROUP=$(stat -c "%G" "$PLUGIN_REPOS_DIR")
PERM_BITS=$(stat -c "%a" "$PLUGIN_REPOS_DIR")
if [ "$OWNER" = "root" ] && [ "$GROUP" = "$ACTUAL_USER" ] && [ "$PERM_BITS" = "775" ]; then
echo -e " ${GREEN}✓ Correct permissions${NC}"
else
echo -e " ${RED}✗ Incorrect permissions${NC}"
echo " Expected: root:$ACTUAL_USER 775"
echo " Actual: $OWNER:$GROUP $PERM_BITS"
fi
# Check if root can access
if sudo -u root test -r "$PLUGIN_REPOS_DIR" && sudo -u root test -w "$PLUGIN_REPOS_DIR"; then
echo -e " ${GREEN}✓ Root can read/write${NC}"
else
echo -e " ${RED}✗ Root cannot access${NC}"
fi
# Try to list contents as root
echo " Testing root access:"
if sudo -u root ls "$PLUGIN_REPOS_DIR" >/dev/null 2>&1; then
echo -e " ${GREEN}✓ Root can list directory${NC}"
else
echo -e " ${RED}✗ Root cannot list directory${NC}"
echo " Error: $(sudo -u root ls "$PLUGIN_REPOS_DIR" 2>&1 | head -1)"
fi
else
echo -e " ${YELLOW}⚠ Directory does not exist${NC}"
fi
echo ""
# Test if root can create files
echo "=== Testing Root Write Access ==="
TEST_FILE="$PLUGIN_REPOS_DIR/.permission_test_$$"
if sudo -u root touch "$TEST_FILE" 2>/dev/null; then
echo -e " ${GREEN}✓ Root can create files${NC}"
sudo -u root rm -f "$TEST_FILE"
else
echo -e " ${RED}✗ Root cannot create files${NC}"
echo " Error: $(sudo -u root touch "$TEST_FILE" 2>&1)"
fi
echo ""
# Summary and fix recommendations
echo "=== Summary ==="
NEEDS_FIX=false
if [ ! -d "$PLUGIN_REPOS_DIR" ]; then
echo -e "${YELLOW}⚠ plugin-repos directory does not exist${NC}"
NEEDS_FIX=true
elif [ "$(stat -c "%U:%G" "$PLUGIN_REPOS_DIR" 2>/dev/null)" != "root:$ACTUAL_USER" ] || [ "$(stat -c "%a" "$PLUGIN_REPOS_DIR" 2>/dev/null)" != "775" ]; then
echo -e "${RED}✗ plugin-repos has incorrect permissions${NC}"
NEEDS_FIX=true
fi
if [ "$NEEDS_FIX" = true ]; then
echo ""
echo "=== Fix Commands ==="
echo "Run these commands to fix permissions:"
echo ""
echo "sudo mkdir -p $PLUGIN_REPOS_DIR"
echo "sudo chown root:$ACTUAL_USER $PLUGIN_REPOS_DIR"
echo "sudo chmod 775 $PLUGIN_REPOS_DIR"
echo ""
echo "Or run the fix script:"
echo "sudo bash $PROJECT_ROOT_DIR/scripts/fix_perms/fix_plugin_permissions.sh"
else
echo -e "${GREEN}✓ All permissions look correct${NC}"
fi
echo ""

View File

@@ -0,0 +1,167 @@
#!/bin/bash
# Web Interface Diagnostic Script
# Run this on your Raspberry Pi to diagnose web interface issues
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ LED Matrix Web Interface Diagnostic Tool ║"
echo "╚══════════════════════════════════════════════════════════════╝"
echo ""
# Color codes for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Get script directory and project root (parent of scripts/)
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$PROJECT_DIR"
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${BLUE}1. SERVICE STATUS${NC}"
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
if sudo systemctl is-active --quiet ledmatrix-web; then
echo -e "${GREEN}✓ Service is RUNNING${NC}"
sudo systemctl status ledmatrix-web --no-pager | head -n 15
else
echo -e "${RED}✗ Service is NOT RUNNING${NC}"
sudo systemctl status ledmatrix-web --no-pager | head -n 15
fi
echo ""
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${BLUE}2. CONFIGURATION CHECK${NC}"
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
# Check if config file exists
if [ -f "$PROJECT_DIR/config/config.json" ]; then
echo -e "${GREEN}✓ Config file found${NC}"
# Check web_display_autostart setting
AUTOSTART=$(cat "$PROJECT_DIR/config/config.json" | grep -o '"web_display_autostart"[[:space:]]*:[[:space:]]*[a-z]*' | grep -o '[a-z]*$')
if [ "$AUTOSTART" == "true" ]; then
echo -e "${GREEN}✓ web_display_autostart: true${NC}"
else
echo -e "${YELLOW}⚠ web_display_autostart: ${AUTOSTART:-not set}${NC}"
echo -e "${YELLOW} Web interface will not start unless this is set to true${NC}"
fi
else
echo -e "${RED}✗ Config file not found at: $PROJECT_DIR/config/config.json${NC}"
fi
echo ""
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${BLUE}3. FILE STRUCTURE CHECK${NC}"
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
# Check critical files
declare -a REQUIRED_FILES=(
"web_interface/app.py"
"web_interface/start.py"
"web_interface/requirements.txt"
"web_interface/blueprints/api_v3.py"
"web_interface/blueprints/pages_v3.py"
"scripts/utils/start_web_conditionally.py"
)
ALL_FILES_OK=true
for file in "${REQUIRED_FILES[@]}"; do
if [ -f "$PROJECT_DIR/$file" ]; then
echo -e "${GREEN}${NC} $file"
else
echo -e "${RED}${NC} $file ${RED}(MISSING)${NC}"
ALL_FILES_OK=false
fi
done
if [ "$ALL_FILES_OK" = true ]; then
echo -e "\n${GREEN}✓ All required files present${NC}"
else
echo -e "\n${RED}✗ Some files are missing - reorganization may be incomplete${NC}"
fi
echo ""
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${BLUE}4. PYTHON IMPORT TEST${NC}"
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
# Test Python imports
echo -n "Testing Flask app import... "
IMPORT_OUTPUT=$(python3 -c "import sys; sys.path.insert(0, '$PROJECT_DIR'); from web_interface.app import app; print('OK')" 2>&1)
if [ $? -eq 0 ]; then
echo -e "${GREEN}✓ SUCCESS${NC}"
else
echo -e "${RED}✗ FAILED${NC}"
echo -e "${RED}Error details:${NC}"
echo "$IMPORT_OUTPUT"
fi
echo ""
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${BLUE}5. NETWORK STATUS${NC}"
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
# Check if port 5000 is in use
if sudo netstat -tlnp 2>/dev/null | grep -q ":5000 " || sudo ss -tlnp 2>/dev/null | grep -q ":5000 "; then
echo -e "${GREEN}✓ Port 5000 is in use (web interface may be running)${NC}"
if command -v netstat &> /dev/null; then
sudo netstat -tlnp | grep ":5000 "
else
sudo ss -tlnp | grep ":5000 "
fi
else
echo -e "${YELLOW}⚠ Port 5000 is not in use (web interface not listening)${NC}"
fi
echo ""
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${BLUE}6. RECENT SERVICE LOGS (Last 30 lines)${NC}"
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
sudo journalctl -u ledmatrix-web -n 30 --no-pager
echo ""
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${BLUE}7. RECOMMENDATIONS${NC}"
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
# Provide recommendations based on findings
if ! sudo systemctl is-active --quiet ledmatrix-web; then
echo -e "${YELLOW}→ Service is not running. Try:${NC}"
echo " sudo systemctl start ledmatrix-web"
fi
if [ "$AUTOSTART" != "true" ]; then
echo -e "${YELLOW}→ Enable web_display_autostart in config/config.json${NC}"
fi
if [ "$ALL_FILES_OK" = false ]; then
echo -e "${YELLOW}→ Some files are missing. You may need to:${NC}"
echo " - Check git status: git status"
echo " - Restore files: git checkout ."
echo " - Or re-run the reorganization"
fi
echo ""
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${GREEN}QUICK COMMANDS:${NC}"
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
echo "View live logs:"
echo " sudo journalctl -u ledmatrix-web -f"
echo ""
echo "Restart service:"
echo " sudo systemctl restart ledmatrix-web"
echo ""
echo "Test manual startup:"
echo " cd $PROJECT_DIR && python3 web_interface/start.py"
echo ""
echo "Check service status:"
echo " sudo systemctl status ledmatrix-web"
echo ""
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""

194
scripts/diagnose_web_ui.sh Executable file
View File

@@ -0,0 +1,194 @@
#!/bin/bash
# LEDMatrix Web UI Diagnostic Script
# Run this on your Raspberry Pi to diagnose web UI startup issues
set -e
echo "=========================================="
echo "LEDMatrix Web UI Diagnostic Report"
echo "=========================================="
echo ""
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Check if running as root or with sudo
if [ "$EUID" -ne 0 ]; then
echo -e "${YELLOW}Warning: Some checks require sudo. Running what we can...${NC}"
SUDO=""
else
SUDO=""
fi
PROJECT_DIR="${HOME}/LEDMatrix"
echo "1. Checking service status..."
echo "------------------------------"
if systemctl is-active --quiet ledmatrix-web 2>/dev/null || sudo systemctl is-active --quiet ledmatrix-web 2>/dev/null; then
echo -e "${GREEN}✓ Service is ACTIVE${NC}"
STATUS=$(sudo systemctl status ledmatrix-web --no-pager -l | head -n 3)
echo "$STATUS"
else
echo -e "${RED}✗ Service is NOT running${NC}"
sudo systemctl status ledmatrix-web --no-pager -l | head -n 10 || echo "Service may not be installed"
fi
echo ""
echo "2. Checking if service is enabled..."
echo "------------------------------"
if systemctl is-enabled --quiet ledmatrix-web 2>/dev/null || sudo systemctl is-enabled --quiet ledmatrix-web 2>/dev/null; then
echo -e "${GREEN}✓ Service is enabled to start on boot${NC}"
else
echo -e "${YELLOW}⚠ Service is NOT enabled (won't start on boot)${NC}"
fi
echo ""
echo "3. Checking configuration file..."
echo "------------------------------"
if [ -f "${PROJECT_DIR}/config/config.json" ]; then
echo -e "${GREEN}✓ Config file exists${NC}"
AUTOSTART=$(grep -o '"web_display_autostart":\s*\(true\|false\)' "${PROJECT_DIR}/config/config.json" | grep -o '\(true\|false\)' || echo "not found")
if [ "$AUTOSTART" = "true" ]; then
echo -e "${GREEN}✓ web_display_autostart is set to TRUE${NC}"
elif [ "$AUTOSTART" = "false" ]; then
echo -e "${RED}✗ web_display_autostart is set to FALSE (web UI won't start!)${NC}"
echo " Fix: Edit config.json and set 'web_display_autostart': true"
else
echo -e "${YELLOW}⚠ web_display_autostart setting not found (defaults to false)${NC}"
echo " Fix: Add 'web_display_autostart': true to config.json"
fi
else
echo -e "${RED}✗ Config file NOT FOUND at ${PROJECT_DIR}/config/config.json${NC}"
fi
echo ""
echo "4. Checking recent service logs..."
echo "------------------------------"
RECENT_LOGS=$(sudo journalctl -u ledmatrix-web -n 30 --no-pager 2>/dev/null || echo "No logs available")
if [ -n "$RECENT_LOGS" ] && [ "$RECENT_LOGS" != "No logs available" ]; then
echo "$RECENT_LOGS"
echo ""
echo "Key messages from logs:"
echo "$RECENT_LOGS" | grep -i "web_display_autostart\|Configuration\|Launching\|will not\|Failed\|Error\|Starting" || echo " (no key messages found)"
else
echo -e "${YELLOW}⚠ No recent logs found${NC}"
fi
echo ""
echo "5. Checking web interface files..."
echo "------------------------------"
FILES_TO_CHECK=(
"scripts/utils/start_web_conditionally.py"
"web_interface/start.py"
"web_interface/app.py"
"web_interface/requirements.txt"
"web_interface/blueprints/api_v3.py"
"web_interface/blueprints/pages_v3.py"
)
for file in "${FILES_TO_CHECK[@]}"; do
if [ -f "${PROJECT_DIR}/${file}" ]; then
echo -e "${GREEN}${file}${NC}"
else
echo -e "${RED}${file} MISSING${NC}"
fi
done
echo ""
echo "6. Checking Python import test..."
echo "------------------------------"
cd "${PROJECT_DIR}" 2>/dev/null || {
echo -e "${RED}✗ Cannot access project directory: ${PROJECT_DIR}${NC}"
echo ""
exit 1
}
if python3 -c "from web_interface.app import app; print('OK')" 2>&1; then
echo -e "${GREEN}✓ Flask app imports successfully${NC}"
else
echo -e "${RED}✗ Flask app import FAILED${NC}"
echo " Error details:"
python3 -c "from web_interface.app import app; print('OK')" 2>&1 | head -n 10
fi
echo ""
echo "7. Checking port 5000 availability..."
echo "------------------------------"
if command -v lsof &> /dev/null; then
PORT_CHECK=$(sudo lsof -i :5000 2>/dev/null || echo "")
if [ -z "$PORT_CHECK" ]; then
echo -e "${GREEN}✓ Port 5000 is available${NC}"
else
echo -e "${YELLOW}⚠ Port 5000 is in use:${NC}"
echo "$PORT_CHECK"
fi
elif command -v ss &> /dev/null; then
PORT_CHECK=$(sudo ss -tlnp | grep :5000 || echo "")
if [ -z "$PORT_CHECK" ]; then
echo -e "${GREEN}✓ Port 5000 is available${NC}"
else
echo -e "${YELLOW}⚠ Port 5000 is in use:${NC}"
echo "$PORT_CHECK"
fi
else
echo -e "${YELLOW}⚠ Cannot check port (lsof/ss not available)${NC}"
fi
echo ""
echo "8. Checking service file..."
echo "------------------------------"
if [ -f "/etc/systemd/system/ledmatrix-web.service" ]; then
echo -e "${GREEN}✓ Service file exists${NC}"
echo " Location: /etc/systemd/system/ledmatrix-web.service"
echo " WorkingDirectory: $(grep WorkingDirectory /etc/systemd/system/ledmatrix-web.service | cut -d'=' -f2 || echo 'not found')"
echo " ExecStart: $(grep ExecStart /etc/systemd/system/ledmatrix-web.service | cut -d'=' -f2 || echo 'not found')"
else
echo -e "${RED}✗ Service file NOT FOUND${NC}"
echo " Run: sudo ./install_web_service.sh (if available)"
fi
echo ""
echo "9. Checking dependencies..."
echo "------------------------------"
if [ -f "${PROJECT_DIR}/web_interface/requirements.txt" ]; then
echo "Checking if Flask is installed..."
if python3 -c "import flask; print(f'Flask {flask.__version__}')" 2>/dev/null; then
echo -e "${GREEN}✓ Flask is installed${NC}"
else
echo -e "${RED}✗ Flask is NOT installed${NC}"
echo " Run: pip3 install --break-system-packages -r web_interface/requirements.txt"
fi
else
echo -e "${YELLOW}⚠ requirements.txt not found${NC}"
fi
echo ""
echo "10. Manual startup test (dry run)..."
echo "------------------------------"
echo "To test manual startup, run:"
echo " cd ${PROJECT_DIR}"
echo " python3 web_interface/start.py"
echo ""
echo "=========================================="
echo "Diagnostic Summary"
echo "=========================================="
echo ""
echo "Most common issues:"
echo " 1. web_display_autostart is false or missing in config.json"
echo " 2. Service not enabled or not started"
echo " 3. Missing dependencies (Flask, etc.)"
echo " 4. Import errors in web_interface/app.py"
echo " 5. Port 5000 already in use"
echo ""
echo "Next steps:"
echo " - Review the checks above for any RED ✗ marks"
echo " - Check recent logs: sudo journalctl -u ledmatrix-web -n 50 --no-pager"
echo " - Follow logs in real-time: sudo journalctl -u ledmatrix-web -f"
echo " - Try manual start: cd ${PROJECT_DIR} && python3 web_interface/start.py"
echo ""
echo "=========================================="

111
scripts/emergency_reconnect.sh Executable file
View File

@@ -0,0 +1,111 @@
#!/bin/bash
# Emergency WiFi Reconnection Script
# Use this if captive portal testing fails and you need to reconnect to your network
set -e
echo "=========================================="
echo "Emergency WiFi Reconnection"
echo "=========================================="
echo ""
# Check if running as root or with sudo
if [ "$EUID" -ne 0 ]; then
echo "This script requires sudo privileges."
echo "Please run: sudo $0"
exit 1
fi
# Stop AP mode services
echo "1. Stopping AP mode services..."
systemctl stop hostapd 2>/dev/null || true
systemctl stop dnsmasq 2>/dev/null || true
echo " ✓ AP mode services stopped"
echo ""
# Check WiFi interface
echo "2. Checking WiFi interface..."
if ! ip link show wlan0 > /dev/null 2>&1; then
echo " ✗ WiFi interface wlan0 not found"
echo " Please check your WiFi adapter"
exit 1
fi
# Enable WiFi if disabled
if ! nmcli radio wifi | grep -q "enabled"; then
echo " Enabling WiFi..."
nmcli radio wifi on
sleep 2
fi
echo " ✓ WiFi interface ready"
echo ""
# List available networks
echo "3. Scanning for available networks..."
echo ""
nmcli device wifi list
echo ""
# Prompt for network credentials
read -p "Enter network SSID: " SSID
if [ -z "$SSID" ]; then
echo "Error: SSID cannot be empty"
exit 1
fi
read -sp "Enter password (leave empty for open networks): " PASSWORD
echo ""
# Connect to network
echo ""
echo "4. Connecting to '$SSID'..."
if [ -z "$PASSWORD" ]; then
nmcli device wifi connect "$SSID"
else
nmcli device wifi connect "$SSID" password "$PASSWORD"
fi
# Wait for connection
echo " Waiting for connection..."
sleep 5
# Check connection status
echo ""
echo "5. Verifying connection..."
if nmcli device status | grep -q "wlan0.*connected"; then
echo " ✓ Connected successfully!"
# Get IP address
IP=$(ip addr show wlan0 2>/dev/null | grep "inet " | awk '{print $2}' | cut -d/ -f1 | head -1)
if [ -n "$IP" ]; then
echo " IP Address: $IP"
fi
# Test internet connectivity
echo ""
echo "6. Testing internet connectivity..."
if ping -c 2 -W 2 8.8.8.8 > /dev/null 2>&1; then
echo " ✓ Internet connection working!"
else
echo " ⚠ Connected to WiFi but no internet access"
echo " Check your router/gateway configuration"
fi
else
echo " ✗ Connection failed"
echo ""
echo "Troubleshooting:"
echo "1. Verify SSID and password are correct"
echo "2. Check if network is in range"
echo "3. Try: nmcli device wifi list"
echo "4. Check: nmcli device status"
exit 1
fi
echo ""
echo "=========================================="
echo "Reconnection Complete!"
echo "=========================================="
echo ""
echo "You can now access the Pi at: http://${IP:-<check-ip>}:5000"
echo "Or via SSH: ssh user@${IP:-<check-ip>}"

View File

@@ -1,120 +0,0 @@
#!/usr/bin/env python3
import json
import sys
import os
def enable_news_manager():
"""Enable the news manager in the configuration"""
config_path = "config/config.json"
try:
# Load current config
with open(config_path, 'r') as f:
config = json.load(f)
# Enable news manager
if 'news_manager' not in config:
print("News manager configuration not found!")
return False
config['news_manager']['enabled'] = True
# Save updated config
with open(config_path, 'w') as f:
json.dump(config, f, indent=4)
print("SUCCESS: News manager enabled successfully!")
print(f"Enabled feeds: {config['news_manager']['enabled_feeds']}")
print(f"Headlines per feed: {config['news_manager']['headlines_per_feed']}")
print(f"Update interval: {config['news_manager']['update_interval']} seconds")
return True
except Exception as e:
print(f"ERROR: Error enabling news manager: {e}")
return False
def disable_news_manager():
"""Disable the news manager in the configuration"""
config_path = "config/config.json"
try:
# Load current config
with open(config_path, 'r') as f:
config = json.load(f)
# Disable news manager
if 'news_manager' in config:
config['news_manager']['enabled'] = False
# Save updated config
with open(config_path, 'w') as f:
json.dump(config, f, indent=4)
print("SUCCESS: News manager disabled successfully!")
else:
print("News manager configuration not found!")
return True
except Exception as e:
print(f"ERROR: Error disabling news manager: {e}")
return False
def show_status():
"""Show current news manager status"""
config_path = "config/config.json"
try:
with open(config_path, 'r') as f:
config = json.load(f)
if 'news_manager' not in config:
print("News manager configuration not found!")
return
news_config = config['news_manager']
print("News Manager Status:")
print("=" * 30)
print(f"Enabled: {news_config.get('enabled', False)}")
print(f"Update Interval: {news_config.get('update_interval', 300)} seconds")
print(f"Scroll Speed: {news_config.get('scroll_speed', 2)} pixels/frame")
print(f"Scroll Delay: {news_config.get('scroll_delay', 0.02)} seconds/frame")
print(f"Headlines per Feed: {news_config.get('headlines_per_feed', 2)}")
print(f"Enabled Feeds: {news_config.get('enabled_feeds', [])}")
print(f"Rotation Enabled: {news_config.get('rotation_enabled', True)}")
print(f"Rotation Threshold: {news_config.get('rotation_threshold', 3)}")
print(f"Font Size: {news_config.get('font_size', 12)}")
custom_feeds = news_config.get('custom_feeds', {})
if custom_feeds:
print("Custom Feeds:")
for name, url in custom_feeds.items():
print(f" {name}: {url}")
else:
print("No custom feeds configured")
except Exception as e:
print(f"ERROR: Error reading configuration: {e}")
def main():
if len(sys.argv) < 2:
print("Usage: python3 scripts/enable_news_manager.py [enable|disable|status]")
sys.exit(1)
command = sys.argv[1].lower()
if command == "enable":
enable_news_manager()
elif command == "disable":
disable_news_manager()
elif command == "status":
show_status()
else:
print("Invalid command. Use: enable, disable, or status")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,109 @@
#!/bin/bash
# Emergency script to fix internet connectivity issues
# Run this if the Pi can't access the internet after AP mode testing
echo "=========================================="
echo "Internet Connectivity Fix Script"
echo "=========================================="
echo ""
# 1. Disable IP forwarding
echo "1. Disabling IP forwarding..."
sudo sysctl -w net.ipv4.ip_forward=0
echo " ✓ IP forwarding disabled"
# 2. Stop and disable dnsmasq (if it's interfering)
echo ""
echo "2. Checking dnsmasq..."
if sudo systemctl is-active dnsmasq > /dev/null 2>&1; then
echo " ⚠ dnsmasq is running - stopping it..."
sudo systemctl stop dnsmasq
sudo systemctl disable dnsmasq
echo " ✓ dnsmasq stopped"
else
echo " ✓ dnsmasq is not running"
fi
# 3. Restore dnsmasq config if backup exists
echo ""
echo "3. Checking dnsmasq config..."
if [ -f /etc/dnsmasq.conf.backup ]; then
echo " ⚠ Found backup config - restoring..."
sudo cp /etc/dnsmasq.conf.backup /etc/dnsmasq.conf
echo " ✓ Config restored"
else
echo " ✓ No backup config found (normal)"
fi
# 4. Restart NetworkManager to restore DNS
echo ""
echo "4. Restarting NetworkManager..."
sudo systemctl restart NetworkManager
sleep 2
echo " ✓ NetworkManager restarted"
# 5. Check DNS resolution
echo ""
echo "5. Testing DNS resolution..."
if nslookup google.com > /dev/null 2>&1; then
echo " ✓ DNS resolution working"
else
echo " ✗ DNS resolution failed"
echo " → Try: sudo systemctl restart systemd-resolved"
fi
# 6. Test internet connectivity
echo ""
echo "6. Testing internet connectivity..."
if ping -c 2 8.8.8.8 > /dev/null 2>&1; then
echo " ✓ Internet connectivity working"
else
echo " ✗ Internet connectivity failed"
echo " → Check: ip route show"
echo " → Check: ip addr show"
fi
# 7. Check if AP mode is still active
echo ""
echo "7. Checking AP mode status..."
if sudo systemctl is-active hostapd > /dev/null 2>&1; then
echo " ⚠ AP mode is still active!"
echo " → To disable: cd ~/LEDMatrix && python3 -c 'from src.wifi_manager import WiFiManager; wm = WiFiManager(); wm.disable_ap_mode()'"
else
echo " ✓ AP mode is not active"
fi
# 8. Remove any leftover iptables rules
echo ""
echo "8. Checking iptables rules..."
if command -v iptables > /dev/null 2>&1; then
# Try to remove any port 80 redirect rules
sudo iptables -t nat -D PREROUTING -i wlan0 -p tcp --dport 80 -j REDIRECT --to-port 5000 2>/dev/null
sudo iptables -D INPUT -i wlan0 -p tcp --dport 80 -j ACCEPT 2>/dev/null
echo " ✓ Cleaned up iptables rules (if any existed)"
else
echo " → iptables not available (normal on some systems)"
fi
echo ""
echo "=========================================="
echo "Fix Complete"
echo "=========================================="
echo ""
echo "If internet still doesn't work:"
echo "1. Check: ip route show"
echo "2. Check: cat /etc/resolv.conf"
echo "3. Restart network: sudo systemctl restart NetworkManager"
echo "4. Check Ethernet connection: ip link show eth0"
echo ""

View File

@@ -0,0 +1,137 @@
#!/bin/bash
# LEDMatrix Assets Permissions Fix Script
# This script fixes permissions on the assets directory so the application can download and save team logos
echo "Fixing LEDMatrix assets directory permissions..."
# Get the real user (not root when running with sudo)
REAL_USER=${SUDO_USER:-$USER}
# Resolve the home directory of the real user robustly
if command -v getent >/dev/null 2>&1; then
REAL_HOME=$(getent passwd "$REAL_USER" | cut -d: -f6)
else
REAL_HOME=$(eval echo ~"$REAL_USER")
fi
REAL_GROUP=$(id -gn "$REAL_USER")
# Get the project directory
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
ASSETS_DIR="$PROJECT_DIR/assets"
echo "Project directory: $PROJECT_DIR"
echo "Assets directory: $ASSETS_DIR"
echo "Real user: $REAL_USER"
echo "Real group: $REAL_GROUP"
# Check if assets directory exists
if [ ! -d "$ASSETS_DIR" ]; then
echo "Error: Assets directory does not exist at $ASSETS_DIR"
exit 1
fi
echo ""
echo "Fixing permissions for assets directory and subdirectories..."
# Set ownership of the entire assets directory to the real user
echo "Setting ownership of assets directory..."
if sudo chown -R "$REAL_USER:$REAL_GROUP" "$ASSETS_DIR"; then
echo "✓ Set assets directory ownership to $REAL_USER:$REAL_GROUP"
else
echo "✗ Failed to set assets directory ownership"
exit 1
fi
# Set permissions to allow read/write for owner, group, and others (for root service user)
# Note: 777 allows root (service user) to write, which is necessary when service runs as root
echo "Setting permissions for assets directory..."
if sudo chmod -R 777 "$ASSETS_DIR"; then
echo "✓ Set assets directory permissions to 777 (writable by root service user)"
else
echo "✗ Failed to set assets directory permissions"
exit 1
fi
# Specifically ensure the sports logos directories are writable
SPORTS_DIRS=(
"sports/ncaa_logos"
"sports/nfl_logos"
"sports/nba_logos"
"sports/nhl_logos"
"sports/mlb_logos"
"sports/milb_logos"
"sports/soccer_logos"
)
echo ""
echo "Ensuring sports logo directories are writable..."
for SPORTS_DIR in "${SPORTS_DIRS[@]}"; do
FULL_PATH="$ASSETS_DIR/$SPORTS_DIR"
echo ""
echo "Checking directory: $FULL_PATH"
if [ -d "$FULL_PATH" ]; then
echo " - Directory exists"
echo " - Current permissions:"
ls -ld "$FULL_PATH"
# Ensure the directory is writable by both the real user and root (service user)
# Use 777 permissions to allow root (service) to write, or set group ownership
sudo chmod 777 "$FULL_PATH"
sudo chown "$REAL_USER:$REAL_GROUP" "$FULL_PATH"
echo " - Updated permissions:"
ls -ld "$FULL_PATH"
# Test write access for real user
echo " - Testing write access as $REAL_USER..."
if sudo -u "$REAL_USER" test -w "$FULL_PATH"; then
echo "$FULL_PATH is writable by $REAL_USER"
else
echo "$FULL_PATH is not writable by $REAL_USER"
fi
# Test write access for root (service user)
echo " - Testing write access as root (service user)..."
if sudo test -w "$FULL_PATH"; then
echo "$FULL_PATH is writable by root"
else
echo "$FULL_PATH is not writable by root"
fi
else
echo " - Directory does not exist, creating it..."
sudo mkdir -p "$FULL_PATH"
sudo chown "$REAL_USER:$REAL_GROUP" "$FULL_PATH"
sudo chmod 777 "$FULL_PATH"
echo " - Created directory with proper permissions (writable by root and $REAL_USER)"
fi
done
echo ""
echo "Testing write access to ncaa_logos directory specifically..."
NCAA_DIR="$ASSETS_DIR/sports/ncaa_logos"
if [ -d "$NCAA_DIR" ]; then
# Create a test file to verify write access
TEST_FILE="$NCAA_DIR/.permission_test"
if sudo -u "$REAL_USER" touch "$TEST_FILE" 2>/dev/null; then
echo "✓ Successfully created test file in ncaa_logos directory"
sudo -u "$REAL_USER" rm -f "$TEST_FILE"
echo "✓ Successfully removed test file"
else
echo "✗ Failed to create test file in ncaa_logos directory"
echo " This indicates the permission fix did not work properly"
fi
else
echo "✗ ncaa_logos directory does not exist"
fi
echo ""
echo "Assets permissions fix completed!"
echo ""
echo "The application should now be able to download and save team logos."
echo "If you still see permission errors, check which user is running the LEDMatrix service"
echo "and ensure it matches the owner above ($REAL_USER)."
echo ""
echo "You may need to restart the LEDMatrix service for the changes to take effect:"
echo " sudo systemctl restart ledmatrix.service"

View File

@@ -0,0 +1,87 @@
#!/bin/bash
# LEDMatrix Cache Permissions Fix Script
# This script fixes permissions on all known cache directories so they're writable by the daemon or current user
# Also sets up placeholder logo directories for sports managers
echo "Fixing LEDMatrix cache directory permissions..."
# Get the real user (not root when running with sudo)
REAL_USER=${SUDO_USER:-$USER}
# Resolve the home directory of the real user robustly
if command -v getent >/dev/null 2>&1; then
REAL_HOME=$(getent passwd "$REAL_USER" | cut -d: -f6)
else
REAL_HOME=$(eval echo ~"$REAL_USER")
fi
REAL_GROUP=$(id -gn "$REAL_USER")
# Known cache directories for LEDMatrix. Use the actual user's home instead of a hard-coded path.
CACHE_DIRS=(
"/var/cache/ledmatrix"
"$REAL_HOME/.ledmatrix_cache"
)
for CACHE_DIR in "${CACHE_DIRS[@]}"; do
echo ""
echo "Checking cache directory: $CACHE_DIR"
if [ ! -d "$CACHE_DIR" ]; then
echo " - Directory does not exist. Creating it..."
sudo mkdir -p "$CACHE_DIR"
fi
echo " - Current permissions:"
ls -ld "$CACHE_DIR"
echo " - Fixing permissions..."
# Make directory writable by services regardless of user context
sudo chmod 777 "$CACHE_DIR"
sudo chown "$REAL_USER":"$REAL_GROUP" "$CACHE_DIR"
echo " - Updated permissions:"
ls -ld "$CACHE_DIR"
echo " - Testing write access as $REAL_USER..."
if sudo -u "$REAL_USER" test -w "$CACHE_DIR"; then
echo "$CACHE_DIR is now writable by $REAL_USER"
else
echo "$CACHE_DIR is still not writable by $REAL_USER"
fi
echo " - Permissions fix complete for $CACHE_DIR."
done
# Set up placeholder logos directory for sports managers
echo ""
echo "Setting up placeholder logos directory for sports managers..."
PLACEHOLDER_DIR="/var/cache/ledmatrix/placeholder_logos"
if [ ! -d "$PLACEHOLDER_DIR" ]; then
echo "Creating placeholder logos directory: $PLACEHOLDER_DIR"
sudo mkdir -p "$PLACEHOLDER_DIR"
sudo chown "$REAL_USER":"$REAL_GROUP" "$PLACEHOLDER_DIR"
sudo chmod 777 "$PLACEHOLDER_DIR"
else
echo "Placeholder logos directory already exists: $PLACEHOLDER_DIR"
sudo chmod 777 "$PLACEHOLDER_DIR"
sudo chown "$REAL_USER":"$REAL_GROUP" "$PLACEHOLDER_DIR"
fi
echo " - Current permissions:"
ls -ld "$PLACEHOLDER_DIR"
echo " - Testing write access as $REAL_USER..."
if sudo -u "$REAL_USER" test -w "$PLACEHOLDER_DIR"; then
echo " ✓ Placeholder logos directory is writable by $REAL_USER"
else
echo " ✗ Placeholder logos directory is not writable by $REAL_USER"
fi
# Test with daemon user (which the system might run as)
if sudo -u daemon test -w "$PLACEHOLDER_DIR" 2>/dev/null; then
echo " ✓ Placeholder logos directory is writable by daemon user"
else
echo " ✗ Placeholder logos directory is not writable by daemon user"
fi
echo ""
echo "All cache directory permission fixes attempted."
echo "If you still see errors, check which user is running the LEDMatrix service and ensure it matches the owner above."
echo ""
echo "The system will now create placeholder logos in:"
echo " $PLACEHOLDER_DIR"
echo "This should eliminate the permission denied warnings for sports logos."

View File

@@ -0,0 +1,21 @@
#!/bin/bash
"""
Script to fix NHL cache issues on Raspberry Pi.
This will clear the NHL cache and restart the display service.
"""
echo "=========================================="
echo "Fixing NHL Cache Issues"
echo "=========================================="
# Clear NHL cache
echo "Clearing NHL cache..."
python3 clear_nhl_cache.py
# Restart the display service to force fresh data fetch
echo "Restarting display service..."
sudo systemctl restart ledmatrix.service
echo "NHL cache cleared and service restarted!"
echo "NHL managers should now fetch fresh data from ESPN API."
echo "Check the logs to see if NHL games are now being displayed."

View File

@@ -0,0 +1,93 @@
#!/bin/bash
# Fix permissions for the plugins directory
# This script sets up proper permissions for both root service and web service access
echo "Fixing permissions for plugins directory..."
# Get the actual user who invoked sudo
if [ -n "$SUDO_USER" ]; then
ACTUAL_USER="$SUDO_USER"
else
ACTUAL_USER=$(whoami)
fi
# Get the project root directory (parent of scripts directory)
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
PROJECT_ROOT_DIR="$( cd "$SCRIPT_DIR/../.." && pwd )"
PLUGINS_DIR="$PROJECT_ROOT_DIR/plugins"
PLUGIN_REPOS_DIR="$PROJECT_ROOT_DIR/plugin-repos"
echo "Project root directory: $PROJECT_ROOT_DIR"
echo "Plugins directory: $PLUGINS_DIR"
echo "Plugin-repos directory: $PLUGIN_REPOS_DIR"
echo "Actual user: $ACTUAL_USER"
echo ""
# Check and fix home directory permissions if needed
# Home directory needs at least 755 (or 750) so root can traverse it
USER_HOME=$(eval echo ~$ACTUAL_USER)
if [ -d "$USER_HOME" ]; then
HOME_PERMS=$(stat -c "%a" "$USER_HOME")
if [ "$HOME_PERMS" = "700" ]; then
echo "⚠ Home directory has restrictive permissions (700)"
echo " Root cannot traverse /home/$ACTUAL_USER to access subdirectories"
echo " Fixing home directory permissions to 755..."
chmod 755 "$USER_HOME"
echo "✓ Home directory permissions fixed"
else
echo "✓ Home directory permissions OK ($HOME_PERMS)"
fi
fi
echo ""
# Ensure plugins directory exists
if [ ! -d "$PLUGINS_DIR" ]; then
echo "Creating plugins directory..."
mkdir -p "$PLUGINS_DIR"
fi
# Set ownership to root:ACTUAL_USER for mixed access
# Root service can read/write, web service (ACTUAL_USER) can read/write
echo "Setting ownership to root:$ACTUAL_USER..."
sudo chown -R root:"$ACTUAL_USER" "$PLUGINS_DIR"
# Set directory permissions (775: rwxrwxr-x)
# Root: read/write/execute, Group (ACTUAL_USER): read/write/execute, Others: read/execute
echo "Setting directory permissions to 2775 (rwxrwxr-x + sticky bit)..."
find "$PLUGINS_DIR" -type d -exec sudo chmod 2775 {} \;
# Set file permissions (664: rw-rw-r--)
# Root: read/write, Group (ACTUAL_USER): read/write, Others: read
echo "Setting file permissions to 664..."
find "$PLUGINS_DIR" -type f -exec sudo chmod 664 {} \;
# Also ensure plugin-repos directory exists with proper permissions
# This is where plugins installed via the plugin store are stored
if [ ! -d "$PLUGIN_REPOS_DIR" ]; then
echo "Creating plugin-repos directory..."
mkdir -p "$PLUGIN_REPOS_DIR"
fi
echo "Setting ownership of plugin-repos to root:$ACTUAL_USER..."
sudo chown -R root:"$ACTUAL_USER" "$PLUGIN_REPOS_DIR"
echo "Setting plugin-repos directory permissions to 2775 (rwxrwxr-x + sticky bit)..."
find "$PLUGIN_REPOS_DIR" -type d -exec sudo chmod 2775 {} \;
echo "Setting plugin-repos file permissions to 664..."
find "$PLUGIN_REPOS_DIR" -type f -exec sudo chmod 664 {} \;
echo "Plugin permissions fixed successfully!"
echo ""
echo "Directory structure:"
echo "plugins/:"
ls -la "$PLUGINS_DIR" 2>/dev/null || echo " (empty or not accessible)"
echo ""
echo "plugin-repos/:"
ls -la "$PLUGIN_REPOS_DIR" 2>/dev/null || echo " (empty or not accessible)"
echo ""
echo "Permissions summary:"
echo "- Root service: Can read/write plugins (for PWM hardware access)"
echo "- Web service ($ACTUAL_USER): Can read/write plugins (for installation)"
echo "- Others: Can read plugins"

View File

@@ -0,0 +1,105 @@
#!/bin/bash
# LED Matrix Web Interface Permissions Fix Script
# This script fixes permissions for the web interface to access logs and system commands
set -e
echo "Fixing LED Matrix Web Interface permissions..."
# Get the current user (should be the user running the web interface)
WEB_USER=$(whoami)
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
echo "Detected web interface user: $WEB_USER"
echo "Project directory: $PROJECT_DIR"
# Check if running as root
if [ "$EUID" -eq 0 ]; then
echo "Error: This script should not be run as root."
echo "Run it as the user that will be running the web interface."
exit 1
fi
echo ""
echo "This script will:"
echo "1. Add the web user to the 'systemd-journal' group for log access"
echo "2. Add the web user to the 'adm' group for additional system access"
echo "3. Configure sudoers for passwordless access to system commands"
echo "4. Set proper file permissions"
echo ""
# Ask for confirmation
read -p "Do you want to proceed? (y/N): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Permission fix cancelled."
exit 0
fi
echo ""
echo "Step 1: Adding user to systemd-journal group..."
if sudo usermod -a -G systemd-journal "$WEB_USER"; then
echo "✓ Added $WEB_USER to systemd-journal group"
else
echo "✗ Failed to add user to systemd-journal group"
fi
echo ""
echo "Step 2: Adding user to adm group..."
if sudo usermod -a -G adm "$WEB_USER"; then
echo "✓ Added $WEB_USER to adm group"
else
echo "✗ Failed to add user to adm group"
fi
echo ""
echo "Step 3: Setting proper file permissions..."
# Set ownership of project files to the web user
if sudo chown -R "$WEB_USER:$WEB_USER" "$PROJECT_DIR"; then
echo "✓ Set project ownership to $WEB_USER"
else
echo "✗ Failed to set project ownership"
fi
# Set proper permissions for config files
if sudo chmod 644 "$PROJECT_DIR/config/config.json" 2>/dev/null; then
echo "✓ Set config file permissions"
else
echo "⚠ Config file permissions not set (file may not exist)"
fi
echo ""
echo "Step 4: Testing journal access..."
# Test if the user can now access journal logs
if journalctl --user-unit=ledmatrix.service --no-pager --lines=1 > /dev/null 2>&1; then
echo "✓ Journal access test passed"
elif sudo -u "$WEB_USER" journalctl --no-pager --lines=1 > /dev/null 2>&1; then
echo "✓ Journal access test passed (with sudo)"
else
echo "⚠ Journal access test failed - you may need to log out and back in"
fi
echo ""
echo "Step 5: Testing sudo access..."
# Test sudo access for system commands
if sudo -n systemctl status ledmatrix.service > /dev/null 2>&1; then
echo "✓ Sudo access test passed"
else
echo "⚠ Sudo access test failed - you may need to run configure_web_sudo.sh"
fi
echo ""
echo "Permission fix completed!"
echo ""
echo "IMPORTANT: For group changes to take effect, you need to:"
echo "1. Log out and log back in, OR"
echo "2. Run: newgrp systemd-journal"
echo "3. Restart the web interface service:"
echo " sudo systemctl restart ledmatrix-web.service"
echo ""
echo "After logging back in, test journal access with:"
echo " journalctl --no-pager --lines=5"
echo ""
echo "If you still have sudo issues, run:"
echo " ./configure_web_sudo.sh"

19
scripts/install/README.md Normal file
View File

@@ -0,0 +1,19 @@
# Installation Scripts
This directory contains scripts for installing and configuring the LEDMatrix system.
## Scripts
- **`install_service.sh`** - Installs the main LED Matrix display service (systemd)
- **`install_web_service.sh`** - Installs the web interface service (systemd)
- **`install_wifi_monitor.sh`** - Installs the WiFi monitor daemon service
- **`setup_cache.sh`** - Sets up persistent cache directory with proper permissions
- **`configure_web_sudo.sh`** - Configures passwordless sudo access for web interface actions
- **`migrate_config.sh`** - Migrates configuration files to new formats (if needed)
## Usage
These scripts are typically called by `first_time_install.sh` in the project root, but can also be run individually if needed.
**Note:** Most installation scripts require `sudo` privileges to install systemd services and configure system settings.

View File

@@ -0,0 +1,135 @@
#!/bin/bash
# LED Matrix Web Interface Sudo Configuration Script
# This script configures passwordless sudo access for the web interface user
set -e
echo "Configuring passwordless sudo access for LED Matrix Web Interface..."
# Get the current user (should be the user running the web interface)
WEB_USER=$(whoami)
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "Detected web interface user: $WEB_USER"
echo "Project directory: $PROJECT_DIR"
# Check if running as root
if [ "$EUID" -eq 0 ]; then
echo "Error: This script should not be run as root."
echo "Run it as the user that will be running the web interface."
exit 1
fi
# Get the full paths to commands
PYTHON_PATH=$(which python3)
SYSTEMCTL_PATH=$(which systemctl)
REBOOT_PATH=$(which reboot)
POWEROFF_PATH=$(which poweroff)
BASH_PATH=$(which bash)
JOURNALCTL_PATH=$(which journalctl)
echo "Command paths:"
echo " Python: $PYTHON_PATH"
echo " Systemctl: $SYSTEMCTL_PATH"
echo " Reboot: $REBOOT_PATH"
echo " Poweroff: $POWEROFF_PATH"
echo " Bash: $BASH_PATH"
echo " Journalctl: $JOURNALCTL_PATH"
# Create a temporary sudoers file
TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$"
cat > "$TEMP_SUDOERS" << EOF
# LED Matrix Web Interface passwordless sudo configuration
# This allows the web interface user to run specific commands without a password
# Allow $WEB_USER to run specific commands without a password for the LED Matrix web interface
$WEB_USER ALL=(ALL) NOPASSWD: $REBOOT_PATH
$WEB_USER ALL=(ALL) NOPASSWD: $POWEROFF_PATH
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix.service
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix.service
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix.service
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH enable ledmatrix.service
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH disable ledmatrix.service
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH status ledmatrix.service
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix.service
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix-web
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix-web
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web
$WEB_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -u ledmatrix.service *
$WEB_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -u ledmatrix *
$WEB_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -t ledmatrix *
$WEB_USER ALL=(ALL) NOPASSWD: $PYTHON_PATH $PROJECT_DIR/display_controller.py
$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_DIR/start_display.sh
$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_DIR/stop_display.sh
EOF
echo ""
echo "Generated sudoers configuration:"
echo "--------------------------------"
cat "$TEMP_SUDOERS"
echo "--------------------------------"
echo ""
echo "This configuration will allow the web interface to:"
echo "- Start/stop/restart the ledmatrix service"
echo "- Enable/disable the ledmatrix service"
echo "- Check service status"
echo "- View system logs via journalctl"
echo "- Run display_controller.py directly"
echo "- Execute start_display.sh and stop_display.sh"
echo "- Reboot and shutdown the system"
echo ""
# Ask for confirmation
read -p "Do you want to apply this configuration? (y/N): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Configuration cancelled."
rm -f "$TEMP_SUDOERS"
exit 0
fi
# Apply the configuration using visudo
echo "Applying sudoers configuration..."
if sudo cp "$TEMP_SUDOERS" /etc/sudoers.d/ledmatrix_web; then
echo "Configuration applied successfully!"
echo ""
echo "Testing sudo access..."
# Test a few commands
if sudo -n systemctl status ledmatrix.service > /dev/null 2>&1; then
echo "✓ systemctl status ledmatrix.service - OK"
else
echo "✗ systemctl status ledmatrix.service - Failed"
fi
if sudo -n test -f "$PROJECT_DIR/start_display.sh"; then
echo "✓ File access test - OK"
else
echo "✗ File access test - Failed"
fi
echo ""
echo "Configuration complete! The web interface should now be able to:"
echo "- Execute system commands without password prompts"
echo "- Start and stop the LED matrix display"
echo "- Restart the system if needed"
echo ""
echo "You may need to restart the web interface service for changes to take effect:"
echo " sudo systemctl restart ledmatrix-web.service"
else
echo "Error: Failed to apply sudoers configuration."
echo "You may need to run this script with sudo privileges."
rm -f "$TEMP_SUDOERS"
exit 1
fi
# Clean up
rm -f "$TEMP_SUDOERS"
echo ""
echo "Configuration script completed successfully!"

View File

@@ -0,0 +1,152 @@
#!/bin/bash
# LED Matrix WiFi Management Permissions Configuration Script
# This script configures both sudo and PolicyKit permissions for WiFi management
set -e
# Cleanup function for temp files
cleanup() {
rm -f "$TEMP_SUDOERS" "$TEMP_POLKIT" 2>/dev/null || true
}
trap cleanup EXIT
echo "Configuring WiFi management permissions for LED Matrix Web Interface..."
# Get the current user (should be the user running the web interface)
WEB_USER=$(whoami)
echo "Detected web interface user: $WEB_USER"
# Check if running as root
if [ "$EUID" -eq 0 ]; then
echo "Error: This script should not be run as root."
echo "Run it as the user that will be running the web interface."
exit 1
fi
# Get the full paths to commands
NMCLI_PATH=$(which nmcli || echo "/usr/bin/nmcli")
SYSTEMCTL_PATH=$(which systemctl)
echo "Command paths:"
echo " nmcli: $NMCLI_PATH"
echo " systemctl: $SYSTEMCTL_PATH"
# Step 1: Configure sudo permissions for nmcli
echo ""
echo "Step 1: Configuring sudo permissions for nmcli..."
SUDOERS_FILE="/etc/sudoers.d/ledmatrix_wifi"
# Create a temporary sudoers file using mktemp (handles permissions better)
TEMP_SUDOERS=$(mktemp) || {
echo "✗ Failed to create temporary file"
exit 1
}
cat > "$TEMP_SUDOERS" << EOF
# LED Matrix WiFi Management passwordless sudo configuration
# This allows the web interface user to run nmcli commands without a password
# Allow $WEB_USER to run nmcli commands without a password for WiFi management
$WEB_USER ALL=(ALL) NOPASSWD: $NMCLI_PATH device wifi connect *
$WEB_USER ALL=(ALL) NOPASSWD: $NMCLI_PATH device wifi disconnect *
$WEB_USER ALL=(ALL) NOPASSWD: $NMCLI_PATH device disconnect *
$WEB_USER ALL=(ALL) NOPASSWD: $NMCLI_PATH device connect *
$WEB_USER ALL=(ALL) NOPASSWD: $NMCLI_PATH radio wifi on
$WEB_USER ALL=(ALL) NOPASSWD: $NMCLI_PATH radio wifi off
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start hostapd
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop hostapd
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart hostapd
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start dnsmasq
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop dnsmasq
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart dnsmasq
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart NetworkManager
EOF
echo "Generated sudoers configuration:"
echo "--------------------------------"
cat "$TEMP_SUDOERS"
echo "--------------------------------"
# Apply the sudoers configuration
echo ""
echo "Applying sudoers configuration..."
if sudo cp "$TEMP_SUDOERS" "$SUDOERS_FILE"; then
sudo chmod 440 "$SUDOERS_FILE"
echo "✓ Sudoers configuration applied successfully!"
else
echo "✗ Failed to apply sudoers configuration"
rm -f "$TEMP_SUDOERS"
exit 1
fi
rm -f "$TEMP_SUDOERS"
# Step 2: Configure PolicyKit permissions for NetworkManager
echo ""
echo "Step 2: Configuring PolicyKit permissions for NetworkManager..."
POLKIT_RULES_DIR="/etc/polkit-1/rules.d"
POLKIT_RULE_FILE="$POLKIT_RULES_DIR/10-ledmatrix-wifi.rules"
# Create PolicyKit rule using mktemp (handles permissions better)
TEMP_POLKIT=$(mktemp) || {
echo "✗ Failed to create temporary file"
exit 1
}
cat > "$TEMP_POLKIT" << EOF
// LED Matrix WiFi Management PolicyKit rules
// This allows the web interface user to control NetworkManager without authentication
polkit.addRule(function(action, subject) {
if (action.id.indexOf("org.freedesktop.NetworkManager.") == 0 &&
subject.user == "$WEB_USER") {
return polkit.Result.YES;
}
});
EOF
echo "Generated PolicyKit rule:"
echo "--------------------------------"
cat "$TEMP_POLKIT"
echo "--------------------------------"
# Apply the PolicyKit rule
echo ""
echo "Applying PolicyKit rule..."
if sudo cp "$TEMP_POLKIT" "$POLKIT_RULE_FILE"; then
sudo chmod 644 "$POLKIT_RULE_FILE"
echo "✓ PolicyKit rule applied successfully!"
else
echo "✗ Failed to apply PolicyKit rule"
rm -f "$TEMP_POLKIT"
exit 1
fi
rm -f "$TEMP_POLKIT"
# Step 3: Test permissions
echo ""
echo "Step 3: Testing permissions..."
# Test sudo access
if sudo -n "$NMCLI_PATH" device status > /dev/null 2>&1; then
echo "✓ nmcli device status - OK"
else
echo "✗ nmcli device status - Failed (this is expected if not connected)"
fi
echo ""
echo "Configuration complete!"
echo ""
echo "The web interface user ($WEB_USER) now has:"
echo "- Passwordless sudo access to nmcli commands"
echo "- PolicyKit permissions to control NetworkManager"
echo ""
echo "You may need to restart the web interface service for changes to take effect:"
echo " sudo systemctl restart ledmatrix-web.service"
echo ""
echo "Or if running manually, restart your Flask application."

View File

@@ -0,0 +1,126 @@
#!/bin/bash
# Exit on error
set -e
# Get the actual user who invoked sudo
if [ -n "$SUDO_USER" ]; then
ACTUAL_USER="$SUDO_USER"
else
ACTUAL_USER=$(whoami)
fi
# Get the home directory of the actual user
USER_HOME=$(eval echo ~$ACTUAL_USER)
# Determine the Project Root Directory (parent of scripts/install/)
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
echo "Installing LED Matrix Display Service for user: $ACTUAL_USER"
echo "Using home directory: $USER_HOME"
echo "Project root directory: $PROJECT_ROOT_DIR"
# Create a temporary service file for the main display with the correct paths
# Assuming ledmatrix.service template exists and uses /home/ledpi as a placeholder for user home
if [ -f "$PROJECT_ROOT_DIR/systemd/ledmatrix.service" ]; then
sed "s|/home/ledpi|$USER_HOME|g; s|__PROJECT_ROOT_DIR__|$PROJECT_ROOT_DIR|g; s|__USER__|root|g" "$PROJECT_ROOT_DIR/systemd/ledmatrix.service" > /tmp/ledmatrix.service.tmp
# Copy the service file to the systemd directory
sudo cp /tmp/ledmatrix.service.tmp /etc/systemd/system/ledmatrix.service
# Clean up
rm /tmp/ledmatrix.service.tmp
else
echo "WARNING: ledmatrix.service template not found at $PROJECT_ROOT_DIR/systemd/ledmatrix.service. Main display service not configured."
fi
# Reload systemd to recognize the new service (or modified service)
sudo systemctl daemon-reload
if [ -f "/etc/systemd/system/ledmatrix.service" ]; then
echo "Enabling ledmatrix.service (main display) to start on boot..."
sudo systemctl enable ledmatrix.service
echo "Starting ledmatrix.service (main display)..."
sudo systemctl start ledmatrix.service
else
echo "Skipping enable/start for ledmatrix.service as it was not configured."
fi
# === LEDMatrix Web Interface service (ledmatrix-web.service) ===
echo "Installing LEDMatrix Web Interface service (ledmatrix-web.service)..."
WEB_SERVICE_FILE_CONTENT=$(cat <<EOF
[Unit]
Description=LED Matrix Web Interface (Conditional Start)
After=network.target
# Wants=ledmatrix.service
# After=network.target ledmatrix.service
[Service]
Type=simple
ExecStart=/usr/bin/python3 ${PROJECT_ROOT_DIR}/scripts/utils/start_web_conditionally.py
WorkingDirectory=${PROJECT_ROOT_DIR}
StandardOutput=journal
StandardError=journal
User=${ACTUAL_USER}
Restart=on-failure
# Environment="PYTHONUNBUFFERED=1"
[Install]
WantedBy=multi-user.target
EOF
)
# Write the new service file
echo "$WEB_SERVICE_FILE_CONTENT" | sudo tee /etc/systemd/system/ledmatrix-web.service > /dev/null
echo "Reloading systemd daemon for web service..."
sudo systemctl daemon-reload
echo "Enabling ledmatrix-web.service to start on boot..."
sudo systemctl enable ledmatrix-web.service
echo "Starting ledmatrix-web.service..."
sudo systemctl start ledmatrix-web.service
echo "LEDMatrix Web Interface service (ledmatrix-web.service) installation complete."
echo "It will start based on the 'web_display_autostart' setting in config/config.json."
# === End of LEDMatrix Web Interface service ===
# Check the status
echo "Service status for main display (ledmatrix.service):"
sudo systemctl status ledmatrix.service || echo "ledmatrix.service not found or failed to get status."
echo "Service status for web interface (ledmatrix-web.service):"
sudo systemctl status ledmatrix-web.service || echo "ledmatrix-web.service not found or failed to get status."
echo ""
echo "LED Matrix Services have been processed."
echo ""
echo "To stop the main display when you SSH in:"
echo " sudo systemctl stop ledmatrix.service"
echo "To stop the web interface:"
echo " sudo systemctl stop ledmatrix-web.service"
echo ""
echo "To check if the main display service is running:"
echo " sudo systemctl status ledmatrix.service"
echo "To check if the web interface service is running:"
echo " sudo systemctl status ledmatrix-web.service"
echo ""
echo "To restart the main display service:"
echo " sudo systemctl restart ledmatrix.service"
echo "To restart the web interface service:"
echo " sudo systemctl restart ledmatrix-web.service"
echo ""
echo "To view logs for the main display:"
echo " journalctl -u ledmatrix.service"
echo "To view logs for the web interface:"
echo " journalctl -u ledmatrix-web.service"
echo ""
echo "To disable autostart for the main display:"
echo " sudo systemctl disable ledmatrix.service"
echo "To disable autostart for the web interface:"
echo " sudo systemctl disable ledmatrix-web.service"

View File

@@ -0,0 +1,114 @@
#!/bin/bash
# LED Matrix Web Interface Service Installer
# This script installs and enables the web interface systemd service
set -e
echo "Installing LED Matrix Web Interface Service..."
# Get the actual user who invoked sudo
if [ -n "$SUDO_USER" ]; then
ACTUAL_USER="$SUDO_USER"
else
ACTUAL_USER=$(whoami)
fi
# Get the home directory of the actual user
USER_HOME=$(eval echo ~$ACTUAL_USER)
# Determine the Project Root Directory (parent of scripts/install/)
PROJECT_ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
echo "Installing for user: $ACTUAL_USER"
echo "Project root directory: $PROJECT_ROOT_DIR"
# Check if running as root
if [ "$EUID" -ne 0 ]; then
echo "Please run as root (use sudo)"
exit 1
fi
# Generate the service file dynamically with the correct paths
echo "Generating service file with dynamic paths..."
WEB_SERVICE_FILE_CONTENT=$(cat <<EOF
[Unit]
Description=LED Matrix Web Interface Service
After=network.target
[Service]
Type=simple
User=${ACTUAL_USER}
WorkingDirectory=${PROJECT_ROOT_DIR}
Environment=USE_THREADING=1
ExecStart=/usr/bin/python3 ${PROJECT_ROOT_DIR}/scripts/utils/start_web_conditionally.py
Restart=on-failure
RestartSec=10
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=ledmatrix-web
# Automatically create and manage cache directory
CacheDirectory=ledmatrix
CacheDirectoryMode=0775
[Install]
WantedBy=multi-user.target
EOF
)
# Write the service file to systemd directory
echo "Writing service file to /etc/systemd/system/ledmatrix-web.service"
echo "$WEB_SERVICE_FILE_CONTENT" > /etc/systemd/system/ledmatrix-web.service
# Ensure cache directory exists with proper permissions
# This is a fallback for older systemd versions that don't support CacheDirectory
# Systemd 239+ will automatically create it via CacheDirectory directive
echo "Setting up cache directory..."
CACHE_DIR="/var/cache/ledmatrix"
if [ ! -d "$CACHE_DIR" ]; then
mkdir -p "$CACHE_DIR"
# Set group ownership to allow both root and web user access
# Try to use ACTUAL_USER's group, fallback to root if that fails
if getent group "$ACTUAL_USER" > /dev/null 2>&1; then
chown root:"$ACTUAL_USER" "$CACHE_DIR" 2>/dev/null || chown root:root "$CACHE_DIR"
else
chown root:root "$CACHE_DIR"
fi
chmod 775 "$CACHE_DIR"
echo "✓ Cache directory created: $CACHE_DIR"
else
# Ensure permissions are correct
chmod 775 "$CACHE_DIR" 2>/dev/null || true
# Try to set group ownership if possible
if getent group "$ACTUAL_USER" > /dev/null 2>&1; then
chown root:"$ACTUAL_USER" "$CACHE_DIR" 2>/dev/null || true
fi
echo "✓ Cache directory exists: $CACHE_DIR"
fi
# Reload systemd to recognize the new service
echo "Reloading systemd..."
systemctl daemon-reload
# Enable the service to start on boot
echo "Enabling ledmatrix-web.service..."
systemctl enable ledmatrix-web.service
# Start the service
echo "Starting ledmatrix-web.service..."
systemctl start ledmatrix-web.service
# Check service status
echo "Checking service status..."
systemctl status ledmatrix-web.service --no-pager
echo ""
echo "Web interface service installed and started!"
echo "The web interface will now start automatically when:"
echo "1. The system boots"
echo "2. The 'web_display_autostart' setting is true in config/config.json"
echo ""
echo "To check the service status: systemctl status ledmatrix-web.service"
echo "To view logs: journalctl -u ledmatrix-web.service -f"
echo "To stop the service: systemctl stop ledmatrix-web.service"
echo "To disable autostart: systemctl disable ledmatrix-web.service"

View File

@@ -0,0 +1,185 @@
#!/bin/bash
# WiFi Monitor Service Installation Script
# Installs the WiFi monitor daemon service for LED Matrix
set -e
# Get the actual user who invoked sudo
if [ -n "$SUDO_USER" ]; then
ACTUAL_USER="$SUDO_USER"
else
ACTUAL_USER=$(whoami)
fi
# Get the home directory of the actual user
USER_HOME=$(eval echo ~$ACTUAL_USER)
# Determine the Project Root Directory (parent of scripts/install/)
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd)
echo "Installing LED Matrix WiFi Monitor Service for user: $ACTUAL_USER"
echo "Using home directory: $USER_HOME"
echo "Project root directory: $PROJECT_ROOT_DIR"
# Check if required packages are installed
echo ""
echo "Checking for required packages..."
MISSING_PACKAGES=()
if ! command -v hostapd >/dev/null 2>&1; then
MISSING_PACKAGES+=("hostapd")
fi
if ! command -v dnsmasq >/dev/null 2>&1; then
MISSING_PACKAGES+=("dnsmasq")
fi
if ! command -v nmcli >/dev/null 2>&1 && ! command -v iwlist >/dev/null 2>&1; then
MISSING_PACKAGES+=("network-manager")
fi
if [ ${#MISSING_PACKAGES[@]} -gt 0 ]; then
echo "⚠ The following packages are required for WiFi setup:"
for pkg in "${MISSING_PACKAGES[@]}"; do
echo " - $pkg"
done
echo ""
read -p "Install these packages now? (y/N): " -n 1 -r
echo ""
if [[ $REPLY =~ ^[Yy]$ ]]; then
sudo apt update
sudo apt install -y "${MISSING_PACKAGES[@]}"
echo "✓ Packages installed"
else
echo "⚠ Skipping package installation. WiFi setup may not work correctly."
fi
fi
# Create service file with correct paths
echo ""
echo "Creating systemd service file..."
SERVICE_FILE_CONTENT=$(cat <<EOF
[Unit]
Description=LED Matrix WiFi Monitor Daemon
After=network.target
Wants=network.target
[Service]
Type=simple
User=root
WorkingDirectory=$PROJECT_ROOT_DIR
ExecStart=/usr/bin/python3 $PROJECT_ROOT_DIR/scripts/utils/wifi_monitor_daemon.py --interval 30
Restart=on-failure
RestartSec=10
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=ledmatrix-wifi-monitor
[Install]
WantedBy=multi-user.target
EOF
)
echo "$SERVICE_FILE_CONTENT" | sudo tee /etc/systemd/system/ledmatrix-wifi-monitor.service > /dev/null
# Check WiFi connection status before enabling service
echo ""
echo "Checking WiFi connection status..."
WIFI_CONNECTED=false
ETHERNET_CONNECTED=false
# Check WiFi status
if command -v nmcli >/dev/null 2>&1; then
# Check if WiFi is connected
WIFI_STATUS=$(nmcli -t -f DEVICE,TYPE,STATE device status 2>/dev/null | grep -i wifi || echo "")
if echo "$WIFI_STATUS" | grep -q "connected"; then
WIFI_CONNECTED=true
SSID=$(nmcli -t -f active,ssid device wifi 2>/dev/null | grep "^yes:" | cut -d: -f2 | head -1)
if [ -n "$SSID" ]; then
echo "✓ WiFi is connected to: $SSID"
else
echo "✓ WiFi is connected"
fi
else
echo "⚠ WiFi is not connected"
fi
# Check Ethernet status
ETH_STATUS=$(nmcli -t -f DEVICE,TYPE,STATE device status 2>/dev/null | grep -E "ethernet|eth" || echo "")
if echo "$ETH_STATUS" | grep -q "connected"; then
ETHERNET_CONNECTED=true
echo "✓ Ethernet is connected"
fi
elif command -v ip >/dev/null 2>&1; then
# Fallback: check using ip command
if ip addr show wlan0 2>/dev/null | grep -q "inet " && ! ip addr show wlan0 2>/dev/null | grep -q "192.168.4.1"; then
WIFI_CONNECTED=true
echo "✓ WiFi appears to be connected (has IP address)"
else
echo "⚠ WiFi does not appear to be connected"
fi
# Check Ethernet
if ip addr show eth0 2>/dev/null | grep -q "inet " || ip addr show 2>/dev/null | grep -E "eth|enp" | grep -q "inet "; then
ETHERNET_CONNECTED=true
echo "✓ Ethernet appears to be connected (has IP address)"
fi
else
echo "⚠ Cannot check network status (nmcli and ip commands not available)"
fi
# Warn if neither WiFi nor Ethernet is connected
if [ "$WIFI_CONNECTED" = false ] && [ "$ETHERNET_CONNECTED" = false ]; then
echo ""
echo "⚠ WARNING: Neither WiFi nor Ethernet is connected!"
echo " The WiFi monitor service will automatically enable AP mode when no network"
echo " connection is detected. This will create a WiFi network named 'LEDMatrix-Setup'"
echo " that you can connect to for initial configuration."
echo ""
echo " If you want to connect to WiFi first, you can:"
echo " 1. Connect to WiFi using: sudo nmcli device wifi connect <SSID> password <password>"
echo " 2. Or connect via Ethernet cable"
echo " 3. Or proceed with installation - you can connect to LEDMatrix-Setup AP after reboot"
echo ""
if [ -z "${ASSUME_YES:-}" ] && [ -z "${LEDMATRIX_ASSUME_YES:-}" ]; then
read -p "Continue with WiFi monitor installation? (y/N): " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Installation cancelled. Connect to WiFi/Ethernet and run this script again."
exit 0
fi
fi
fi
# Reload systemd
echo ""
echo "Reloading systemd..."
sudo systemctl daemon-reload
# Enable and start the service
echo "Enabling WiFi monitor service to start on boot..."
sudo systemctl enable ledmatrix-wifi-monitor.service
echo "Starting WiFi monitor service..."
sudo systemctl start ledmatrix-wifi-monitor.service
# Check service status
echo ""
echo "Checking service status..."
if sudo systemctl is-active --quiet ledmatrix-wifi-monitor.service; then
echo "✓ WiFi monitor service is running"
else
echo "⚠ WiFi monitor service failed to start. Check logs with:"
echo " sudo journalctl -u ledmatrix-wifi-monitor -n 50"
fi
echo ""
echo "WiFi Monitor Service installation complete!"
echo ""
echo "Useful commands:"
echo " sudo systemctl status ledmatrix-wifi-monitor # Check status"
echo " sudo systemctl restart ledmatrix-wifi-monitor # Restart service"
echo " sudo journalctl -u ledmatrix-wifi-monitor -f # View logs"
echo ""

View File

@@ -0,0 +1,43 @@
#!/bin/bash
# LED Matrix Configuration Migration Script
# This script helps migrate existing config.json to the new template-based system
set -e
echo "=========================================="
echo "LED Matrix Configuration Migration Script"
echo "=========================================="
echo ""
# Check if we're in the right directory
if [ ! -f "config/config.template.json" ]; then
echo "Error: config/config.template.json not found."
echo "Please run this script from the LEDMatrix project root directory."
exit 1
fi
# Check if config.json exists
if [ ! -f "config/config.json" ]; then
echo "No existing config.json found. Creating from template..."
cp config/config.template.json config/config.json
echo "✓ Created config/config.json from template"
echo ""
echo "You can now edit config/config.json with your preferences."
exit 0
fi
echo "Existing config.json found. The system will automatically handle migration."
echo ""
echo "What this means:"
echo "- Your current config.json will be preserved"
echo "- New configuration options will be automatically added with default values"
echo "- A backup will be created before any changes"
echo "- The system handles this automatically when it starts"
echo ""
echo "No manual migration is needed. The ConfigManager will handle everything automatically."
echo ""
echo "To see the latest configuration options, you can reference:"
echo " config/config.template.json"
echo ""
echo "Migration complete!"

View File

@@ -0,0 +1,75 @@
#!/bin/bash
# LEDMatrix Cache Setup Script
# This script sets up a persistent cache directory for LEDMatrix
# with proper group permissions for shared access between services
echo "Setting up LEDMatrix persistent cache directory..."
# Create the cache directory
sudo mkdir -p /var/cache/ledmatrix
# Create ledmatrix group if it doesn't exist
if ! getent group ledmatrix > /dev/null 2>&1; then
sudo groupadd ledmatrix
echo "Created ledmatrix group"
else
echo "ledmatrix group already exists"
fi
# Get the real user (not root when running with sudo)
REAL_USER=${SUDO_USER:-$USER}
# Add current user to ledmatrix group
sudo usermod -a -G ledmatrix "$REAL_USER"
echo "Added $REAL_USER to ledmatrix group"
# Add daemon user to ledmatrix group (for main LEDMatrix service)
if id daemon > /dev/null 2>&1; then
sudo usermod -a -G ledmatrix daemon
echo "Added daemon user to ledmatrix group"
fi
# Set group ownership to ledmatrix
sudo chown -R :ledmatrix /var/cache/ledmatrix
# Set directory permissions: 775 (rwxrwxr-x) with setgid bit so new files inherit group
sudo find /var/cache/ledmatrix -type d -exec chmod 775 {} \;
sudo chmod g+s /var/cache/ledmatrix
# Set file permissions: 660 (rw-rw----) for group-readable cache files
sudo find /var/cache/ledmatrix -type f -exec chmod 660 {} \;
echo ""
echo "Cache directory created: /var/cache/ledmatrix"
echo "Group ownership: ledmatrix"
echo "Permissions: 775 with setgid (group-writable, new files inherit group)"
# Test if the directory is writable by the current user
if [ -w /var/cache/ledmatrix ]; then
echo "✓ Cache directory is writable by current user ($REAL_USER)"
else
echo "✗ Cache directory is not writable by current user"
echo " Note: You may need to log out and back in for group changes to take effect"
fi
# Test if the directory is writable by daemon user (which the system runs as)
if id daemon > /dev/null 2>&1; then
if sudo -u daemon test -w /var/cache/ledmatrix; then
echo "✓ Cache directory is writable by daemon user"
else
echo "✗ Cache directory is not writable by daemon user"
echo " This might cause issues when running with sudo"
fi
fi
echo ""
echo "Setup complete! LEDMatrix will now use persistent caching."
echo "The cache will survive system restarts."
echo ""
echo "IMPORTANT: If you just added yourself to the ledmatrix group,"
echo "you may need to log out and back in (or run 'newgrp ledmatrix')"
echo "for the group membership to take effect."
echo ""
echo "If you see warnings about using temporary cache directory,"
echo "the system will automatically fall back to /tmp/ledmatrix_cache/"

View File

@@ -6,7 +6,7 @@ then falls back to pip with --break-system-packages
import subprocess
import sys
import os
import warnings
from pathlib import Path
def install_via_apt(package_name):
@@ -15,10 +15,7 @@ def install_via_apt(package_name):
# Map pip package names to apt package names
apt_package_map = {
'flask': 'python3-flask',
'flask_socketio': 'python3-flask-socketio',
'PIL': 'python3-pil',
'socketio': 'python3-socketio',
'eventlet': 'python3-eventlet',
'freetype': 'python3-freetype',
'psutil': 'python3-psutil',
'werkzeug': 'python3-werkzeug',
@@ -65,11 +62,15 @@ def install_via_pip(package_name):
def check_package_installed(package_name):
"""Check if a package is already installed."""
try:
__import__(package_name)
return True
except ImportError:
return False
# Suppress deprecation warnings when checking if packages are installed
# (we're just checking, not using them)
with warnings.catch_warnings():
warnings.filterwarnings('ignore', category=DeprecationWarning)
try:
__import__(package_name)
return True
except ImportError:
return False
def main():
"""Main installation function."""
@@ -78,10 +79,7 @@ def main():
# List of required packages
required_packages = [
'flask',
'flask_socketio',
'PIL',
'socketio',
'eventlet',
'freetype',
'psutil',
'werkzeug',
@@ -109,33 +107,52 @@ def main():
# Install packages that don't have apt equivalents
special_packages = [
'timezonefinder==6.2.0',
'google-auth-oauthlib==1.0.0',
'google-auth-httplib2==0.1.0',
'google-api-python-client==2.86.0',
'timezonefinder>=6.5.0,<7.0.0',
'google-auth-oauthlib>=1.2.0,<2.0.0',
'google-auth-httplib2>=0.2.0,<1.0.0',
'google-api-python-client>=2.147.0,<3.0.0',
'spotipy',
'icalevents',
'python-engineio'
'python-socketio>=5.11.0,<6.0.0',
'python-engineio>=4.9.0,<5.0.0'
]
for package in special_packages:
if not install_via_pip(package):
failed_packages.append(package)
# Install rgbmatrix module from local source
print("Installing rgbmatrix module...")
try:
rgbmatrix_path = Path(__file__).parent / 'rpi-rgb-led-matrix-master' / 'bindings' / 'python'
if rgbmatrix_path.exists():
subprocess.check_call([
sys.executable, '-m', 'pip', 'install', '--break-system-packages', '-e', str(rgbmatrix_path)
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
print("rgbmatrix module installed successfully")
else:
print("Warning: rgbmatrix source not found")
except subprocess.CalledProcessError as e:
print(f"Failed to install rgbmatrix module: {e}")
failed_packages.append('rgbmatrix')
# Install rgbmatrix module from local source (optional - may already be installed in Step 6)
# Check if already installed first
if check_package_installed('rgbmatrix'):
print("rgbmatrix module already installed, skipping...")
else:
print("Installing rgbmatrix module from local source...")
try:
# Get project root (parent of scripts directory)
PROJECT_ROOT = Path(__file__).parent.parent
rgbmatrix_path = PROJECT_ROOT / 'rpi-rgb-led-matrix-master' / 'bindings' / 'python'
if rgbmatrix_path.exists():
# Check if the module has been built (look for setup.py)
setup_py = rgbmatrix_path / 'setup.py'
if setup_py.exists():
# Try installing - use regular install, not editable mode
# This is optional for web interface and should already be installed in Step 6
subprocess.check_call([
sys.executable, '-m', 'pip', 'install', '--break-system-packages', str(rgbmatrix_path)
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
print("rgbmatrix module installed successfully")
else:
print("Warning: rgbmatrix setup.py not found, module may need to be built first")
print(" This is normal if Step 6 hasn't completed yet.")
else:
print("Warning: rgbmatrix source not found (this is normal if Step 6 hasn't run yet)")
except subprocess.CalledProcessError as e:
# Don't fail the whole installation - rgbmatrix is optional for web interface
# and should be installed in Step 6 of first_time_install.sh
print(f"Warning: Failed to install rgbmatrix module: {e}")
print(" This is normal if rgbmatrix hasn't been built yet (Step 6).")
print(" The web interface will work without it.")
# Don't add to failed_packages since it's optional
if failed_packages:
print(f"\nFailed to install the following packages: {failed_packages}")

View File

@@ -0,0 +1,111 @@
#!/bin/bash
# Script to manually install plugin dependencies
# Use this if automatic dependency installation fails
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${GREEN}=== LEDMatrix Plugin Dependency Installer ===${NC}"
echo ""
# Check if running as root
if [ "$EUID" -eq 0 ]; then
echo -e "${GREEN}Running as root - will install system-wide${NC}"
INSTALL_CMD="pip3 install --break-system-packages --no-cache-dir"
else
echo -e "${YELLOW}Not running as root - will install to user directory${NC}"
echo -e "${YELLOW}Note: User-installed packages won't be accessible to systemd service${NC}"
INSTALL_CMD="pip3 install --user --break-system-packages --no-cache-dir"
fi
echo ""
# Get the directory where this script is located
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
LEDMATRIX_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
PLUGINS_DIR="$LEDMATRIX_DIR/plugins"
echo "LEDMatrix directory: $LEDMATRIX_DIR"
echo "Plugins directory: $PLUGINS_DIR"
echo ""
# Check if plugins directory exists
if [ ! -d "$PLUGINS_DIR" ]; then
echo -e "${RED}Error: Plugins directory not found at $PLUGINS_DIR${NC}"
exit 1
fi
# Find all requirements.txt files in plugin directories
echo -e "${GREEN}Searching for plugin requirements...${NC}"
echo ""
PLUGINS_FOUND=0
PLUGINS_INSTALLED=0
PLUGINS_FAILED=0
for plugin_dir in "$PLUGINS_DIR"/*/ ; do
if [ -d "$plugin_dir" ]; then
plugin_name=$(basename "$plugin_dir")
requirements_file="$plugin_dir/requirements.txt"
if [ -f "$requirements_file" ]; then
PLUGINS_FOUND=$((PLUGINS_FOUND + 1))
echo -e "${GREEN}Found plugin: ${plugin_name}${NC}"
echo " Requirements: $requirements_file"
# Check if requirements file is empty
if [ ! -s "$requirements_file" ]; then
echo -e " ${YELLOW}Skipping: requirements.txt is empty${NC}"
echo ""
continue
fi
# Install dependencies
echo " Installing dependencies..."
if $INSTALL_CMD -r "$requirements_file" 2>&1 | tee /tmp/pip_install_$plugin_name.log; then
echo -e " ${GREEN}✓ Successfully installed dependencies for $plugin_name${NC}"
PLUGINS_INSTALLED=$((PLUGINS_INSTALLED + 1))
else
echo -e " ${RED}✗ Failed to install dependencies for $plugin_name${NC}"
echo -e " ${RED} See /tmp/pip_install_$plugin_name.log for details${NC}"
PLUGINS_FAILED=$((PLUGINS_FAILED + 1))
fi
echo ""
fi
fi
done
# Summary
echo ""
echo -e "${GREEN}=== Installation Summary ===${NC}"
echo "Plugins with requirements found: $PLUGINS_FOUND"
echo -e "${GREEN}Successfully installed: $PLUGINS_INSTALLED${NC}"
if [ $PLUGINS_FAILED -gt 0 ]; then
echo -e "${RED}Failed: $PLUGINS_FAILED${NC}"
fi
echo ""
if [ $PLUGINS_FAILED -gt 0 ]; then
echo -e "${YELLOW}Some plugins failed to install dependencies.${NC}"
echo -e "${YELLOW}Common solutions:${NC}"
echo " 1. Run this script as root: sudo $0"
echo " 2. Check pip logs in /tmp/pip_install_*.log"
echo " 3. Manually install with: sudo pip3 install --break-system-packages -r <requirements.txt>"
echo ""
exit 1
fi
if [ $PLUGINS_FOUND -eq 0 ]; then
echo -e "${YELLOW}No plugins with requirements.txt found${NC}"
else
echo -e "${GREEN}All plugin dependencies installed successfully!${NC}"
echo ""
echo "If you're using systemd service, restart it:"
echo " sudo systemctl restart ledmatrix"
fi

View File

@@ -0,0 +1,343 @@
#!/bin/bash
# Script to normalize all plugins as git submodules
# This ensures uniform plugin management across the repository
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
PLUGINS_DIR="$PROJECT_ROOT/plugins"
GITMODULES="$PROJECT_ROOT/.gitmodules"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
log_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
log_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Check if a plugin is in .gitmodules
is_in_gitmodules() {
local plugin_path="$1"
git config -f "$GITMODULES" --get-regexp "^submodule\." | grep -q "path = $plugin_path$" || return 1
}
# Get submodule URL from .gitmodules
get_submodule_url() {
local plugin_path="$1"
git config -f "$GITMODULES" "submodule.$plugin_path.url" 2>/dev/null || echo ""
}
# Check if directory is a git repo
is_git_repo() {
[[ -d "$1/.git" ]]
}
# Get git remote URL
get_git_remote() {
local plugin_dir="$1"
if is_git_repo "$plugin_dir"; then
(cd "$plugin_dir" && git remote get-url origin 2>/dev/null || echo "")
else
echo ""
fi
}
# Check if directory is a symlink
is_symlink() {
[[ -L "$1" ]]
}
# Check if plugin has GitHub repo
has_github_repo() {
local plugin_name="$1"
local url="https://github.com/ChuckBuilds/ledmatrix-$plugin_name"
local status=$(curl -s -o /dev/null -w "%{http_code}" "$url" 2>/dev/null || echo "0")
[[ "$status" == "200" ]]
}
# Update .gitignore to allow a plugin submodule
update_gitignore() {
local plugin_name="$1"
local plugin_path="plugins/$plugin_name"
local gitignore="$PROJECT_ROOT/.gitignore"
# Check if already in .gitignore exceptions
if grep -q "!plugins/$plugin_name$" "$gitignore" 2>/dev/null; then
log_info "Plugin $plugin_name already in .gitignore exceptions"
return 0
fi
# Find the line with the last plugin exception
local last_line=$(grep -n "!plugins/" "$gitignore" | tail -1 | cut -d: -f1)
if [[ -z "$last_line" ]]; then
log_warn "Could not find plugin exceptions in .gitignore"
return 1
fi
# Add exceptions after the last plugin exception
log_info "Updating .gitignore to allow $plugin_name submodule"
sed -i "${last_line}a!plugins/$plugin_name\n!plugins/$plugin_name/" "$gitignore"
log_success "Updated .gitignore for $plugin_name"
}
# Re-initialize a submodule that appears as regular directory
reinit_submodule() {
local plugin_name="$1"
local plugin_path="plugins/$plugin_name"
local plugin_dir="$PLUGINS_DIR/$plugin_name"
log_info "Re-initializing submodule: $plugin_name"
if ! is_in_gitmodules "$plugin_path"; then
log_error "Plugin $plugin_name is not in .gitmodules"
return 1
fi
local submodule_url=$(get_submodule_url "$plugin_path")
if [[ -z "$submodule_url" ]]; then
log_error "Could not find URL for $plugin_name in .gitmodules"
return 1
fi
# If it's a symlink, remove it first
if is_symlink "$plugin_dir"; then
log_warn "Removing symlink: $plugin_dir"
rm "$plugin_dir"
fi
# If it's a regular directory with .git, we need to handle it carefully
if is_git_repo "$plugin_dir"; then
local remote_url=$(get_git_remote "$plugin_dir")
if [[ "$remote_url" == "$submodule_url" ]] || [[ "$remote_url" == "${submodule_url%.git}" ]] || [[ "${submodule_url%.git}" == "$remote_url" ]]; then
log_info "Directory is already the correct git repo, re-initializing submodule..."
# Remove from git index and re-add as submodule
git rm --cached "$plugin_path" 2>/dev/null || true
rm -rf "$plugin_dir"
else
log_warn "Directory has different remote ($remote_url vs $submodule_url)"
log_warn "Backing up to ${plugin_dir}.backup"
mv "$plugin_dir" "${plugin_dir}.backup"
fi
fi
# Re-add as submodule (use -f to force if needed)
if git submodule add -f "$submodule_url" "$plugin_path" 2>/dev/null; then
log_info "Submodule added successfully"
else
log_info "Submodule already exists, updating..."
git submodule update --init "$plugin_path"
fi
log_success "Re-initialized submodule: $plugin_name"
}
# Convert standalone git repo to submodule
convert_to_submodule() {
local plugin_name="$1"
local plugin_path="plugins/$plugin_name"
local plugin_dir="$PLUGINS_DIR/$plugin_name"
log_info "Converting to submodule: $plugin_name"
if is_in_gitmodules "$plugin_path"; then
log_warn "Plugin $plugin_name is already in .gitmodules, re-initializing instead"
reinit_submodule "$plugin_name"
return 0
fi
if ! is_git_repo "$plugin_dir"; then
log_error "Plugin $plugin_name is not a git repository"
return 1
fi
local remote_url=$(get_git_remote "$plugin_dir")
if [[ -z "$remote_url" ]]; then
log_error "Plugin $plugin_name has no remote URL"
return 1
fi
# If it's a symlink, we need to handle it differently
if is_symlink "$plugin_dir"; then
local target=$(readlink -f "$plugin_dir")
log_warn "Plugin is a symlink to $target"
log_warn "Removing symlink and adding as submodule"
rm "$plugin_dir"
# Update .gitignore first
update_gitignore "$plugin_name"
# Add as submodule
if git submodule add -f "$remote_url" "$plugin_path"; then
log_success "Added submodule: $plugin_name"
return 0
else
log_error "Failed to add submodule"
return 1
fi
fi
# Backup the directory
log_info "Backing up existing directory to ${plugin_dir}.backup"
mv "$plugin_dir" "${plugin_dir}.backup"
# Remove from git index
git rm --cached "$plugin_path" 2>/dev/null || true
# Update .gitignore first
update_gitignore "$plugin_name"
# Add as submodule (use -f to force if .gitignore blocks it)
if git submodule add -f "$remote_url" "$plugin_path"; then
log_success "Converted to submodule: $plugin_name"
log_warn "Backup saved at ${plugin_dir}.backup - you can remove it after verifying"
else
log_error "Failed to add submodule"
log_warn "Restoring backup..."
mv "${plugin_dir}.backup" "$plugin_dir"
return 1
fi
}
# Add new submodule for plugin with GitHub repo
add_new_submodule() {
local plugin_name="$1"
local plugin_path="plugins/$plugin_name"
local plugin_dir="$PLUGINS_DIR/$plugin_name"
local repo_url="https://github.com/ChuckBuilds/ledmatrix-$plugin_name.git"
log_info "Adding new submodule: $plugin_name"
if is_in_gitmodules "$plugin_path"; then
log_warn "Plugin $plugin_name is already in .gitmodules"
return 0
fi
if [[ -e "$plugin_dir" ]]; then
if is_symlink "$plugin_dir"; then
log_warn "Removing symlink: $plugin_dir"
rm "$plugin_dir"
elif is_git_repo "$plugin_dir"; then
log_warn "Directory exists as git repo, converting instead"
convert_to_submodule "$plugin_name"
return 0
else
log_warn "Backing up existing directory to ${plugin_dir}.backup"
mv "$plugin_dir" "${plugin_dir}.backup"
fi
fi
# Remove from git index if it exists
git rm --cached "$plugin_path" 2>/dev/null || true
# Update .gitignore first
update_gitignore "$plugin_name"
# Add as submodule (use -f to force if .gitignore blocks it)
if git submodule add -f "$repo_url" "$plugin_path"; then
log_success "Added new submodule: $plugin_name"
else
log_error "Failed to add submodule"
if [[ -e "${plugin_dir}.backup" ]]; then
log_warn "Restoring backup..."
mv "${plugin_dir}.backup" "$plugin_dir"
fi
return 1
fi
}
# Main processing function
main() {
cd "$PROJECT_ROOT"
log_info "Normalizing all plugins as git submodules..."
echo
# Step 1: Re-initialize submodules that appear as regular directories
log_info "Step 1: Re-initializing existing submodules..."
for plugin in basketball-scoreboard calendar clock-simple odds-ticker olympics-countdown soccer-scoreboard text-display mqtt-notifications; do
if [[ -d "$PLUGINS_DIR/$plugin" ]] && is_in_gitmodules "plugins/$plugin"; then
if ! git submodule status "plugins/$plugin" >/dev/null 2>&1; then
reinit_submodule "$plugin"
else
log_info "Submodule $plugin is already properly initialized"
fi
fi
done
echo
# Step 2: Convert standalone git repos to submodules
log_info "Step 2: Converting standalone git repos to submodules..."
for plugin in baseball-scoreboard ledmatrix-stocks; do
if [[ -d "$PLUGINS_DIR/$plugin" ]] && is_git_repo "$PLUGINS_DIR/$plugin"; then
if ! is_in_gitmodules "plugins/$plugin"; then
convert_to_submodule "$plugin"
fi
fi
done
echo
# Step 2b: Convert symlinks to submodules
log_info "Step 2b: Converting symlinks to submodules..."
for plugin in christmas-countdown ledmatrix-music static-image; do
if [[ -L "$PLUGINS_DIR/$plugin" ]]; then
if ! is_in_gitmodules "plugins/$plugin"; then
convert_to_submodule "$plugin"
fi
fi
done
echo
# Step 3: Add new submodules for plugins with GitHub repos
log_info "Step 3: Adding new submodules for plugins with GitHub repos..."
for plugin in football-scoreboard hockey-scoreboard; do
if [[ -d "$PLUGINS_DIR/$plugin" ]] && has_github_repo "$plugin"; then
if ! is_in_gitmodules "plugins/$plugin"; then
add_new_submodule "$plugin"
fi
fi
done
echo
# Step 4: Report on plugins without GitHub repos
log_info "Step 4: Checking plugins without GitHub repos..."
for plugin in ledmatrix-flights ledmatrix-leaderboard ledmatrix-weather; do
if [[ -d "$PLUGINS_DIR/$plugin" ]]; then
if ! is_in_gitmodules "plugins/$plugin" && ! is_git_repo "$PLUGINS_DIR/$plugin"; then
log_warn "Plugin $plugin has no GitHub repo and is not a git repo"
log_warn " This plugin may be local-only or needs a repository created"
fi
fi
done
echo
# Final: Initialize all submodules
log_info "Finalizing: Initializing all submodules..."
git submodule update --init --recursive
log_success "Plugin normalization complete!"
log_info "Run 'git status' to see changes"
log_info "Run 'git submodule status' to verify all submodules"
}
main "$@"

117
scripts/remove_plugin_backups.sh Executable file
View File

@@ -0,0 +1,117 @@
#!/bin/bash
# Script to safely remove plugin backup directories
# These were created during the plugin-to-submodule conversion
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
PLUGINS_DIR="$PROJECT_ROOT/plugins"
# Colors
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
log_info() {
echo -e "${GREEN}[INFO]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Verify submodules are working
verify_submodules() {
log_info "Verifying submodules are working..."
local issues=0
for submod in football-scoreboard hockey-scoreboard ledmatrix-flights \
ledmatrix-leaderboard ledmatrix-stocks ledmatrix-weather \
mqtt-notifications; do
if [ ! -d "$PLUGINS_DIR/$submod" ]; then
log_error "Submodule directory missing: $submod"
issues=$((issues + 1))
elif [ ! -f "$PLUGINS_DIR/$submod/.git" ]; then
log_error "Submodule .git file missing: $submod"
issues=$((issues + 1))
elif [ ! -f "$PLUGINS_DIR/$submod/manifest.json" ]; then
log_warn "Submodule manifest missing: $submod (may be OK)"
fi
done
if [ $issues -eq 0 ]; then
log_info "All submodules verified ✓"
return 0
else
log_error "Found $issues issues with submodules"
return 1
fi
}
# Remove backup directories
remove_backups() {
log_info "Removing backup directories..."
local removed=0
local total_size=0
for backup in "$PLUGINS_DIR"/*.backup*; do
if [ -d "$backup" ]; then
local name=$(basename "$backup")
local size=$(du -sb "$backup" 2>/dev/null | awk '{print $1}')
total_size=$((total_size + size))
log_info "Removing: $name"
rm -rf "$backup"
removed=$((removed + 1))
fi
done
if [ $removed -gt 0 ]; then
log_info "Removed $removed backup directory(ies)"
log_info "Freed approximately $(numfmt --to=iec-i --suffix=B $total_size 2>/dev/null || echo "$total_size bytes")"
else
log_info "No backup directories found"
fi
}
# Main
main() {
cd "$PROJECT_ROOT"
echo "=== Plugin Backup Removal Script ==="
echo
# Verify submodules first
if ! verify_submodules; then
log_error "Submodule verification failed. Not removing backups."
log_warn "Please fix submodule issues before removing backups."
exit 1
fi
echo
log_warn "This will permanently delete backup directories:"
ls -1d "$PLUGINS_DIR"/*.backup* 2>/dev/null | sed 's|.*/| - |' || echo " (none found)"
echo
read -p "Continue? (y/N): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
log_info "Aborted"
exit 0
fi
remove_backups
log_info "Done!"
}
main "$@"

199
scripts/run_plugin_tests.py Executable file
View File

@@ -0,0 +1,199 @@
#!/usr/bin/env python3
"""
Plugin Test Runner
Discovers and runs tests for LEDMatrix plugins.
Supports both unittest and pytest.
"""
import sys
import os
import argparse
import subprocess
from pathlib import Path
from typing import Optional
# Add project root to path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
def discover_plugin_tests(plugins_dir: Path, plugin_id: Optional[str] = None) -> list:
"""
Discover test files in plugin directories.
Args:
plugins_dir: Plugins directory path
plugin_id: Optional specific plugin ID to test
Returns:
List of test file paths
"""
test_files = []
if plugin_id:
# Test specific plugin
plugin_dir = plugins_dir / plugin_id
if plugin_dir.exists():
test_files.extend(_find_tests_in_dir(plugin_dir))
else:
# Test all plugins
for item in plugins_dir.iterdir():
if not item.is_dir():
continue
if item.name.startswith('.') or item.name.startswith('_'):
continue
test_files.extend(_find_tests_in_dir(item))
return test_files
def _find_tests_in_dir(directory: Path) -> list:
"""Find test files in a directory."""
test_files = []
# Look for test files
patterns = ['test_*.py', '*_test.py', 'tests/test_*.py', 'tests/*_test.py']
for pattern in patterns:
if '/' in pattern:
# Subdirectory pattern
subdir, file_pattern = pattern.split('/', 1)
test_dir = directory / subdir
if test_dir.exists():
test_files.extend(test_dir.glob(file_pattern))
else:
# Direct pattern
test_files.extend(directory.glob(pattern))
return sorted(set(test_files))
def run_unittest_tests(test_files: list, verbose: bool = False) -> int:
"""
Run tests using unittest.
Args:
test_files: List of test file paths
verbose: Enable verbose output
Returns:
Exit code (0 for success, non-zero for failure)
"""
import unittest
# Discover tests
loader = unittest.TestLoader()
suite = unittest.TestSuite()
for test_file in test_files:
# Import the test module
module_name = test_file.stem
spec = importlib.util.spec_from_file_location(module_name, test_file)
if spec and spec.loader:
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# Load tests from module
tests = loader.loadTestsFromModule(module)
suite.addTests(tests)
# Run tests
runner = unittest.TextTestRunner(verbosity=2 if verbose else 1)
result = runner.run(suite)
return 0 if result.wasSuccessful() else 1
def run_pytest_tests(test_files: list, verbose: bool = False, coverage: bool = False) -> int:
"""
Run tests using pytest.
Args:
test_files: List of test file paths
verbose: Enable verbose output
coverage: Generate coverage report
Returns:
Exit code (0 for success, non-zero for failure)
"""
import pytest
args = []
if verbose:
args.append('-v')
else:
args.append('-q')
if coverage:
args.extend(['--cov', 'plugins', '--cov-report', 'html', '--cov-report', 'term'])
# Add test files
args.extend([str(f) for f in test_files])
# Run pytest
exit_code = pytest.main(args)
return exit_code
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(description='Run LEDMatrix plugin tests')
parser.add_argument('--plugin', '-p', help='Test specific plugin ID')
parser.add_argument('--plugins-dir', '-d', default='plugins',
help='Plugins directory (default: plugins)')
parser.add_argument('--runner', '-r', choices=['unittest', 'pytest', 'auto'],
default='auto', help='Test runner to use (default: auto)')
parser.add_argument('--verbose', '-v', action='store_true',
help='Enable verbose output')
parser.add_argument('--coverage', '-c', action='store_true',
help='Generate coverage report (pytest only)')
args = parser.parse_args()
plugins_dir = Path(args.plugins_dir)
if not plugins_dir.exists():
print(f"Error: Plugins directory not found: {plugins_dir}")
return 1
# Discover tests
test_files = discover_plugin_tests(plugins_dir, args.plugin)
if not test_files:
if args.plugin:
print(f"No tests found for plugin: {args.plugin}")
else:
print("No test files found in plugins directory")
return 0
print(f"Found {len(test_files)} test file(s)")
for test_file in test_files:
print(f" - {test_file}")
print()
# Determine runner
runner = args.runner
if runner == 'auto':
# Try pytest first, fall back to unittest
try:
import pytest
runner = 'pytest'
except ImportError:
runner = 'unittest'
# Run tests
if runner == 'pytest':
import importlib.util
return run_pytest_tests(test_files, args.verbose, args.coverage)
else:
import importlib.util
return run_unittest_tests(test_files, args.verbose)
if __name__ == '__main__':
import importlib.util
from typing import Optional
sys.exit(main())

149
scripts/test_captive_portal.sh Executable file
View File

@@ -0,0 +1,149 @@
#!/bin/bash
# Test script for captive portal functionality
# This script tests the captive portal from a device connected to the AP network
set -e
PI_IP="192.168.4.1"
PI_PORT="5000"
BASE_URL="http://${PI_IP}:${PI_PORT}"
echo "=========================================="
echo "Captive Portal Functionality Test"
echo "=========================================="
echo ""
echo "Make sure you're connected to 'LEDMatrix-Setup' network"
echo "Pi IP: ${PI_IP}"
echo "Web Interface Port: ${PI_PORT}"
echo ""
# Colors for output
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Test counter
PASSED=0
FAILED=0
test_result() {
if [ $1 -eq 0 ]; then
echo -e "${GREEN}${NC} $2"
((PASSED++))
else
echo -e "${RED}${NC} $2"
((FAILED++))
fi
}
# Test 1: Check if Pi is reachable
echo "1. Testing Pi connectivity..."
if ping -c 1 -W 2 ${PI_IP} > /dev/null 2>&1; then
test_result 0 "Pi is reachable at ${PI_IP}"
else
test_result 1 "Pi is NOT reachable at ${PI_IP}"
echo " Make sure you're connected to LEDMatrix-Setup network"
exit 1
fi
# Test 2: DNS Redirection
echo ""
echo "2. Testing DNS redirection..."
DNS_RESULT=$(nslookup google.com 2>/dev/null | grep -i "address" | tail -1 | awk '{print $2}')
if [ "$DNS_RESULT" = "${PI_IP}" ]; then
test_result 0 "DNS redirection works (google.com resolves to ${PI_IP})"
else
test_result 1 "DNS redirection failed (got ${DNS_RESULT}, expected ${PI_IP})"
fi
# Test 3: HTTP Redirect
echo ""
echo "3. Testing HTTP redirect..."
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -L --max-time 5 "${BASE_URL}/google.com" 2>/dev/null || echo "000")
if [ "$HTTP_CODE" = "200" ]; then
test_result 0 "HTTP redirect works (got 200, redirected to setup page)"
else
test_result 1 "HTTP redirect failed (got ${HTTP_CODE})"
fi
# Test 4: Captive Portal Detection Endpoints
echo ""
echo "4. Testing captive portal detection endpoints..."
# iOS/macOS
IOS_RESPONSE=$(curl -s --max-time 5 "${BASE_URL}/hotspot-detect.html" 2>/dev/null || echo "")
if echo "$IOS_RESPONSE" | grep -qi "success"; then
test_result 0 "iOS/macOS endpoint works"
else
test_result 1 "iOS/macOS endpoint failed"
fi
# Android
ANDROID_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "${BASE_URL}/generate_204" 2>/dev/null || echo "000")
if [ "$ANDROID_CODE" = "204" ]; then
test_result 0 "Android endpoint works"
else
test_result 1 "Android endpoint failed (got ${ANDROID_CODE})"
fi
# Windows
WIN_RESPONSE=$(curl -s --max-time 5 "${BASE_URL}/connecttest.txt" 2>/dev/null || echo "")
if echo "$WIN_RESPONSE" | grep -qi "microsoft"; then
test_result 0 "Windows endpoint works"
else
test_result 1 "Windows endpoint failed"
fi
# Firefox
FF_RESPONSE=$(curl -s --max-time 5 "${BASE_URL}/success.txt" 2>/dev/null || echo "")
if echo "$FF_RESPONSE" | grep -qi "success"; then
test_result 0 "Firefox endpoint works"
else
test_result 1 "Firefox endpoint failed"
fi
# Test 5: API Endpoints (should NOT redirect)
echo ""
echo "5. Testing API endpoints (should work normally)..."
API_RESPONSE=$(curl -s --max-time 5 "${BASE_URL}/api/v3/wifi/status" 2>/dev/null || echo "")
if echo "$API_RESPONSE" | grep -qi "status"; then
test_result 0 "API endpoints work (not redirected)"
else
test_result 1 "API endpoints failed or were redirected"
fi
# Test 6: Main Interface (should be accessible)
echo ""
echo "6. Testing main interface accessibility..."
MAIN_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "${BASE_URL}/v3" 2>/dev/null || echo "000")
if [ "$MAIN_CODE" = "200" ]; then
test_result 0 "Main interface is accessible"
else
test_result 1 "Main interface failed (got ${MAIN_CODE})"
fi
# Summary
echo ""
echo "=========================================="
echo "Test Summary"
echo "=========================================="
echo -e "${GREEN}Passed: ${PASSED}${NC}"
echo -e "${RED}Failed: ${FAILED}${NC}"
echo ""
if [ $FAILED -eq 0 ]; then
echo -e "${GREEN}All tests passed! Captive portal is working correctly.${NC}"
exit 0
else
echo -e "${YELLOW}Some tests failed. Check the output above for details.${NC}"
echo ""
echo "Troubleshooting tips:"
echo "1. Verify AP mode is active: sudo systemctl status hostapd"
echo "2. Check dnsmasq config: sudo cat /etc/dnsmasq.conf"
echo "3. Check web interface logs: sudo journalctl -u ledmatrix-web -n 50"
echo "4. Verify you're connected to LEDMatrix-Setup network"
exit 1
fi

View File

@@ -0,0 +1,120 @@
#!/bin/bash
# Troubleshooting script for captive portal WiFi setup
# Run this when you can SSH back into the Pi
echo "=========================================="
echo "Captive Portal Troubleshooting"
echo "=========================================="
echo ""
echo "1. Checking AP mode status..."
if sudo systemctl is-active hostapd > /dev/null 2>&1; then
echo " ✓ hostapd is running"
else
echo " ✗ hostapd is NOT running"
fi
if sudo systemctl is-active dnsmasq > /dev/null 2>&1; then
echo " ✓ dnsmasq is running"
else
echo " ✗ dnsmasq is NOT running"
fi
echo ""
echo "2. Checking wlan0 IP address..."
WLAN_IP=$(ip addr show wlan0 | grep "inet 192.168.4.1" | awk '{print $2}')
if [ -n "$WLAN_IP" ]; then
echo " ✓ wlan0 has IP: $WLAN_IP"
else
echo " ✗ wlan0 does NOT have 192.168.4.1"
echo " → Fix: sudo ip addr add 192.168.4.1/24 dev wlan0"
fi
echo ""
echo "3. Checking web server status..."
if sudo systemctl is-active ledmatrix-web > /dev/null 2>&1; then
echo " ✓ Web server (ledmatrix-web) is running"
else
echo " ✗ Web server is NOT running"
echo " → Fix: sudo systemctl start ledmatrix-web"
fi
echo ""
echo "4. Checking if web server is listening on port 5000..."
if sudo netstat -tlnp 2>/dev/null | grep -q ":5000" || sudo ss -tlnp 2>/dev/null | grep -q ":5000"; then
echo " ✓ Web server is listening on port 5000"
sudo netstat -tlnp 2>/dev/null | grep ":5000" || sudo ss -tlnp 2>/dev/null | grep ":5000"
else
echo " ✗ Web server is NOT listening on port 5000"
echo " → Web server may not be running or bound incorrectly"
fi
echo ""
echo "5. Testing web server locally..."
if curl -s http://localhost:5000/v3 > /dev/null 2>&1; then
echo " ✓ Web server responds locally"
else
echo " ✗ Web server does NOT respond locally"
echo " → Web server may have crashed or not started"
fi
echo ""
echo "6. Testing web server from AP IP..."
if curl -s http://192.168.4.1:5000/v3 > /dev/null 2>&1; then
echo " ✓ Web server responds on 192.168.4.1:5000"
else
echo " ✗ Web server does NOT respond on 192.168.4.1:5000"
fi
echo ""
echo "7. Checking firewall rules..."
if command -v ufw > /dev/null 2>&1; then
UFW_STATUS=$(sudo ufw status | head -1)
echo " UFW Status: $UFW_STATUS"
if echo "$UFW_STATUS" | grep -qi "active"; then
echo " → Check if port 5000 is allowed: sudo ufw allow 5000/tcp"
fi
fi
if command -v iptables > /dev/null 2>&1; then
echo " Checking iptables rules for port 5000..."
sudo iptables -L -n | grep -E "5000|ACCEPT.*wlan0" || echo " → No specific rules found"
fi
echo ""
echo "8. Checking DNS resolution..."
if dig @192.168.4.1 google.com > /dev/null 2>&1; then
echo " ✓ DNS is working"
else
echo " ✗ DNS may not be working correctly"
fi
echo ""
echo "9. Checking captive portal detection endpoints..."
for endpoint in "hotspot-detect.html" "generate_204" "connecttest.txt" "success.txt"; do
if curl -s http://192.168.4.1:5000/$endpoint > /dev/null 2>&1; then
echo "$endpoint responds"
else
echo "$endpoint does NOT respond"
fi
done
echo ""
echo "10. Recent web server logs..."
if sudo journalctl -u ledmatrix-web -n 10 --no-pager 2>/dev/null | head -5; then
echo ""
else
echo " → No logs found or service not running"
fi
echo ""
echo "=========================================="
echo "Quick Fixes:"
echo "=========================================="
echo "1. Start web server: sudo systemctl start ledmatrix-web"
echo "2. Enable web server: sudo systemctl enable ledmatrix-web"
echo "3. Check web server logs: sudo journalctl -u ledmatrix-web -f"
echo "4. Restart AP mode: cd ~/LEDMatrix && python3 -c 'from src.wifi_manager import WiFiManager; wm = WiFiManager(); wm.disable_ap_mode(); wm.enable_ap_mode()'"
echo "5. Allow port 5000 in firewall: sudo ufw allow 5000/tcp"
echo ""

27
scripts/utils/README.md Normal file
View File

@@ -0,0 +1,27 @@
# Utility Scripts
This directory contains utility scripts for maintenance and system operations.
## Scripts
- **`clear_cache.py`** - Clears LEDMatrix cache data (specific keys or all cache)
- **`start_web_conditionally.py`** - Conditionally starts the web interface based on config settings
- **`wifi_monitor_daemon.py`** - Background daemon that monitors WiFi/Ethernet connection and manages access point mode
- **`cleanup_venv.sh`** - Cleans up Python virtual environment files
- **`clear_python_cache.sh`** - Clears Python cache files (__pycache__, *.pyc, etc.)
## Usage
### Clear Cache
```bash
python3 scripts/utils/clear_cache.py --list # List cache keys
python3 scripts/utils/clear_cache.py --clear-all # Clear all cache
python3 scripts/utils/clear_cache.py --clear <key> # Clear specific key
```
### Start Web Interface Conditionally
This script is typically called by the systemd service (`ledmatrix-web.service`) and checks the `web_display_autostart` setting in `config/config.json` before starting the web interface.
### WiFi Monitor Daemon
This daemon is typically run as a systemd service (`ledmatrix-wifi-monitor.service`) and automatically manages WiFi access point mode based on network connectivity.

View File

@@ -0,0 +1,23 @@
#!/bin/bash
# Cleanup script to remove virtual environment if it exists
# This script removes the venv_web_v2 directory if it exists
set -e
# Get the directory where this script is located
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
echo "Cleaning up virtual environment..."
# Check if virtual environment exists and remove it
if [ -d "venv_web_v2" ]; then
echo "Removing existing virtual environment..."
rm -rf venv_web_v2
echo "Virtual environment removed successfully"
else
echo "No virtual environment found to remove"
fi
echo "Cleanup complete!"

View File

@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""
Cache clearing utility for LEDMatrix
This script allows manual clearing of specific cache keys or all cache data.
"""
import os
import sys
import json
import argparse
from pathlib import Path
# Add the src directory to the path so we can import our modules
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
from cache_manager import CacheManager
def list_cache_keys(cache_manager):
"""List all available cache keys."""
cache_dir = cache_manager.cache_dir
if not cache_dir or not os.path.exists(cache_dir):
print(f"Cache directory does not exist: {cache_dir}")
return []
cache_files = []
for file in os.listdir(cache_dir):
if file.endswith('.json'):
cache_files.append(file[:-5]) # Remove .json extension
return cache_files
def clear_specific_cache(cache_manager, key):
"""Clear a specific cache key."""
try:
cache_manager.clear_cache(key)
print(f"✓ Cleared cache key: {key}")
return True
except Exception as e:
print(f"✗ Error clearing cache key '{key}': {e}")
return False
def clear_all_cache(cache_manager):
"""Clear all cache data."""
try:
cache_manager.clear_cache()
print("✓ Cleared all cache data")
return True
except Exception as e:
print(f"✗ Error clearing all cache: {e}")
return False
def show_cache_info(cache_manager, key=None):
"""Show information about cache entries."""
if key:
try:
data = cache_manager.get(key)
if data is not None:
print(f"Cache key '{key}' exists with data type: {type(data)}")
if isinstance(data, dict):
print(f" Keys: {list(data.keys())}")
if 'games' in data:
print(f" Number of games: {len(data['games']) if isinstance(data['games'], dict) else 'N/A'}")
elif isinstance(data, list):
print(f" Number of items: {len(data)}")
else:
print(f" Data: {str(data)[:100]}...")
else:
print(f"Cache key '{key}' does not exist or is expired")
except Exception as e:
print(f"Error checking cache key '{key}': {e}")
else:
# Show all cache keys
keys = list_cache_keys(cache_manager)
if keys:
print("Available cache keys:")
for key in sorted(keys):
print(f" - {key}")
else:
print("No cache keys found")
def main():
parser = argparse.ArgumentParser(description='Clear LEDMatrix cache data')
parser.add_argument('--list', '-l', action='store_true',
help='List all available cache keys')
parser.add_argument('--clear-all', '-a', action='store_true',
help='Clear all cache data')
parser.add_argument('--clear', '-c', type=str, metavar='KEY',
help='Clear a specific cache key')
parser.add_argument('--info', '-i', type=str, metavar='KEY',
help='Show information about a specific cache key')
args = parser.parse_args()
# Initialize cache manager
cache_manager = CacheManager()
if args.list:
show_cache_info(cache_manager)
elif args.clear_all:
print("Clearing all cache data...")
clear_all_cache(cache_manager)
elif args.clear:
print(f"Clearing cache key: {args.clear}")
clear_specific_cache(cache_manager, args.clear)
elif args.info:
show_cache_info(cache_manager, args.info)
else:
# Default: show available options
print("LEDMatrix Cache Utility")
print("=" * 30)
print()
print("Available commands:")
print(" --list, -l List all cache keys")
print(" --clear-all, -a Clear all cache data")
print(" --clear KEY, -c Clear specific cache key")
print(" --info KEY, -i Show info about cache key")
print()
print("Examples:")
print(" python clear_cache.py --list")
print(" python clear_cache.py --clear milb_live_api_data")
print(" python clear_cache.py --clear-all")
print(" python clear_cache.py --info milb_upcoming_api_data")
print()
# Show current cache status
show_cache_info(cache_manager)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,20 @@
#!/bin/bash
# Clear Python cache files that might be causing import issues
echo "🧹 Clearing Python cache files..."
# Clear __pycache__ directories
find ~/LEDMatrix -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
# Clear .pyc files
find ~/LEDMatrix -name "*.pyc" -delete 2>/dev/null || true
# Clear Flask session cache if it exists
rm -rf ~/LEDMatrix/web_interface/.webassets-cache 2>/dev/null || true
echo "✅ Python cache cleared"
echo ""
echo "🔄 Now try running the web interface again:"
echo "cd ~/LEDMatrix"
echo "python3 web_interface/start.py"

View File

@@ -0,0 +1,110 @@
import json
import os
import sys
import subprocess
from pathlib import Path
# Get project root directory (parent of scripts/utils/)
PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
CONFIG_FILE = os.path.join(PROJECT_DIR, 'config', 'config.json')
WEB_INTERFACE_SCRIPT = os.path.join(PROJECT_DIR, 'web_interface', 'start.py')
def install_dependencies():
"""Install required dependencies using system Python."""
print("Installing dependencies...")
try:
requirements_file = os.path.join(PROJECT_DIR, 'web_interface', 'requirements.txt')
# Use --ignore-installed to handle system packages (like psutil) that can't be uninstalled
# This allows pip to install even if a system package version conflicts
result = subprocess.run([
sys.executable, '-m', 'pip', 'install', '--break-system-packages', '--ignore-installed', '-r', requirements_file
], capture_output=True, text=True)
if result.returncode != 0:
# Check if the error is just about psutil version conflict
if 'psutil' in result.stderr.lower() and ('uninstall' in result.stderr.lower() or 'cannot uninstall' in result.stderr.lower()):
print("Warning: psutil version conflict detected (system package vs requirements).")
print("Attempting to install other dependencies without psutil...")
# Try installing without psutil
with open(requirements_file, 'r') as f:
lines = f.readlines()
# Filter out psutil line
filtered_lines = [line for line in lines if 'psutil' not in line.lower()]
temp_reqs = os.path.join(PROJECT_DIR, 'web_interface', 'requirements_temp.txt')
with open(temp_reqs, 'w') as f:
f.writelines(filtered_lines)
try:
subprocess.check_call([
sys.executable, '-m', 'pip', 'install', '--break-system-packages', '--ignore-installed', '-r', temp_reqs
])
print("Dependencies installed successfully (psutil skipped - using system version)")
finally:
if os.path.exists(temp_reqs):
os.remove(temp_reqs)
else:
# Re-raise the error if it's not about psutil
print(f"Failed to install dependencies: {result.stderr}")
return False
else:
print("Dependencies installed successfully")
# Install rgbmatrix module from local source (optional - not required for web interface)
print("Installing rgbmatrix module (optional)...")
rgbmatrix_path = Path(PROJECT_DIR) / 'rpi-rgb-led-matrix-master' / 'bindings' / 'python'
if rgbmatrix_path.exists():
try:
subprocess.check_call([
sys.executable, '-m', 'pip', 'install', '--break-system-packages', '-e', str(rgbmatrix_path)
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
print("rgbmatrix module installed successfully")
except subprocess.CalledProcessError:
print("Warning: rgbmatrix module installation failed (not required for web interface, continuing...)")
else:
print("rgbmatrix module path not found (not required for web interface, continuing...)")
return True
except subprocess.CalledProcessError as e:
print(f"Failed to install dependencies: {e}")
return False
def main():
try:
with open(CONFIG_FILE, 'r') as f:
config_data = json.load(f)
except FileNotFoundError:
print(f"Config file {CONFIG_FILE} not found. Web interface will not start.")
sys.exit(0) # Exit gracefully, don't start
except Exception as e:
print(f"Error reading config file {CONFIG_FILE}: {e}. Web interface will not start.")
sys.exit(1) # Exit with error, service might restart depending on config
autostart_enabled = config_data.get("web_display_autostart", False)
# Handle both boolean True and string "on"/"true" values
is_enabled = (autostart_enabled is True) or (isinstance(autostart_enabled, str) and autostart_enabled.lower() in ("on", "true", "yes", "1"))
if is_enabled:
print("Configuration 'web_display_autostart' is enabled. Starting web interface...")
# Install dependencies
if not install_dependencies():
print("Failed to install dependencies. Exiting.")
sys.exit(1)
try:
# Replace the current process with web_interface.py using system Python
# This is important for systemd to correctly manage the web server process.
# Ensure PYTHONPATH is set correctly if web_interface.py has relative imports to src
# The WorkingDirectory in systemd service should handle this for web_interface.py
print(f"Launching web interface v3: {sys.executable} {WEB_INTERFACE_SCRIPT}")
os.execvp(sys.executable, [sys.executable, WEB_INTERFACE_SCRIPT])
except Exception as e:
print(f"Failed to exec web interface: {e}")
sys.exit(1) # Failed to start
else:
print("Configuration 'web_display_autostart' is false or not set. Web interface will not be started.")
sys.exit(0) # Exit gracefully, service considered successful
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""
WiFi Monitor Daemon
Monitors WiFi connection status and automatically enables/disables access point mode
when there is no active WiFi connection.
"""
import sys
import time
import logging
import signal
from pathlib import Path
# Add project root to path (parent of scripts/utils/)
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from src.wifi_manager import WiFiManager
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(sys.stdout),
logging.FileHandler('/var/log/ledmatrix-wifi-monitor.log')
]
)
logger = logging.getLogger(__name__)
class WiFiMonitorDaemon:
"""Daemon to monitor WiFi and manage AP mode"""
def __init__(self, check_interval=30):
"""
Initialize the WiFi monitor daemon
Args:
check_interval: Seconds between WiFi status checks
"""
self.check_interval = check_interval
self.wifi_manager = WiFiManager()
self.running = True
self.last_state = None
# Register signal handlers for graceful shutdown
signal.signal(signal.SIGINT, self._signal_handler)
signal.signal(signal.SIGTERM, self._signal_handler)
def _signal_handler(self, signum, frame):
"""Handle shutdown signals"""
logger.info(f"Received signal {signum}, shutting down...")
self.running = False
def run(self):
"""Main daemon loop"""
logger.info("WiFi Monitor Daemon started")
logger.info(f"Check interval: {self.check_interval} seconds")
while self.running:
try:
# Check WiFi status and manage AP mode
state_changed = self.wifi_manager.check_and_manage_ap_mode()
# Get current status for logging
status = self.wifi_manager.get_wifi_status()
ethernet_connected = self.wifi_manager._is_ethernet_connected()
current_state = {
'connected': status.connected,
'ethernet_connected': ethernet_connected,
'ap_active': status.ap_mode_active,
'ssid': status.ssid
}
# Log state changes
if current_state != self.last_state:
if status.connected:
logger.info(f"WiFi connected: {status.ssid} (IP: {status.ip_address})")
else:
logger.info("WiFi disconnected")
if ethernet_connected:
logger.info("Ethernet connected")
else:
logger.debug("Ethernet disconnected")
if status.ap_mode_active:
logger.info("AP mode active")
else:
logger.debug("AP mode inactive")
self.last_state = current_state.copy()
# Sleep until next check
time.sleep(self.check_interval)
except KeyboardInterrupt:
logger.info("Received keyboard interrupt, shutting down...")
self.running = False
break
except Exception as e:
logger.error(f"Error in monitor loop: {e}", exc_info=True)
# Continue running even if there's an error
time.sleep(self.check_interval)
logger.info("WiFi Monitor Daemon stopped")
# Ensure AP mode is disabled on shutdown if WiFi or Ethernet is connected
try:
status = self.wifi_manager.get_wifi_status()
ethernet_connected = self.wifi_manager._is_ethernet_connected()
if (status.connected or ethernet_connected) and status.ap_mode_active:
if status.connected:
logger.info("Disabling AP mode on shutdown (WiFi is connected)")
elif ethernet_connected:
logger.info("Disabling AP mode on shutdown (Ethernet is connected)")
self.wifi_manager.disable_ap_mode()
except Exception as e:
logger.error(f"Error disabling AP mode on shutdown: {e}")
def main():
"""Main entry point"""
import argparse
parser = argparse.ArgumentParser(description='WiFi Monitor Daemon for LED Matrix')
parser.add_argument(
'--interval',
type=int,
default=30,
help='Check interval in seconds (default: 30)'
)
parser.add_argument(
'--foreground',
action='store_true',
help='Run in foreground (for debugging)'
)
args = parser.parse_args()
daemon = WiFiMonitorDaemon(check_interval=args.interval)
try:
daemon.run()
except Exception as e:
logger.error(f"Fatal error: {e}", exc_info=True)
sys.exit(1)
if __name__ == '__main__':
main()

221
scripts/verify_installation.sh Executable file
View File

@@ -0,0 +1,221 @@
#!/bin/bash
# LED Matrix Installation Verification Script
# This script verifies that the installation was successful
set -e
echo "=========================================="
echo "LED Matrix Installation Verification"
echo "=========================================="
echo ""
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Track overall status
ALL_PASSED=true
check_pass() {
echo -e "${GREEN}${NC} $1"
}
check_fail() {
echo -e "${RED}${NC} $1"
ALL_PASSED=false
}
check_warn() {
echo -e "${YELLOW}${NC} $1"
}
# Determine project root
if [ -f "run.py" ]; then
PROJECT_ROOT="$(pwd)"
elif [ -f "../run.py" ]; then
PROJECT_ROOT="$(cd .. && pwd)"
else
echo "Error: Could not find project root. Please run this script from the LEDMatrix directory."
exit 1
fi
echo "Project root: $PROJECT_ROOT"
echo ""
# 1. Check systemd services
echo "=== Systemd Services ==="
services=("ledmatrix.service" "ledmatrix-web.service" "ledmatrix-wifi-monitor.service")
for service in "${services[@]}"; do
if systemctl list-unit-files | grep -q "$service"; then
if systemctl is-active --quiet "$service" 2>/dev/null; then
check_pass "$service is installed and running"
elif systemctl is-enabled --quiet "$service" 2>/dev/null; then
check_warn "$service is installed and enabled but not running"
else
check_warn "$service is installed but not enabled"
fi
else
check_fail "$service is not installed"
fi
done
echo ""
# 2. Check Python dependencies
echo "=== Python Dependencies ==="
if python3 -c "from rgbmatrix import RGBMatrix, RGBMatrixOptions" 2>/dev/null; then
check_pass "rpi-rgb-led-matrix is installed"
else
check_fail "rpi-rgb-led-matrix is not installed"
fi
# Check key Python packages
packages=("flask" "requests" "PIL" "numpy")
for pkg in "${packages[@]}"; do
if python3 -c "import $pkg" 2>/dev/null; then
check_pass "Python package '$pkg' is installed"
else
check_fail "Python package '$pkg' is not installed"
fi
done
echo ""
# 3. Check configuration files
echo "=== Configuration Files ==="
if [ -f "$PROJECT_ROOT/config/config.json" ]; then
check_pass "config/config.json exists"
else
check_fail "config/config.json is missing"
fi
if [ -f "$PROJECT_ROOT/config/config_secrets.json" ]; then
check_pass "config/config_secrets.json exists"
else
check_warn "config/config_secrets.json is missing (may be optional)"
fi
echo ""
# 4. Check file permissions
echo "=== File Permissions ==="
if [ -d "$PROJECT_ROOT/assets" ]; then
if [ -w "$PROJECT_ROOT/assets" ]; then
check_pass "assets directory is writable"
else
check_warn "assets directory may not be writable"
fi
else
check_fail "assets directory is missing"
fi
if [ -d "/var/cache/ledmatrix" ]; then
if [ -w "/var/cache/ledmatrix" ]; then
check_pass "cache directory exists and is writable"
else
check_warn "cache directory exists but may not be writable"
fi
else
check_warn "cache directory is missing (will be created on first run)"
fi
echo ""
# 5. Check web interface
echo "=== Web Interface ==="
if [ -f "$PROJECT_ROOT/web_interface_v2.py" ]; then
check_pass "web_interface_v2.py exists"
else
check_fail "web_interface_v2.py is missing"
fi
# Check if web service is listening
if systemctl is-active --quiet ledmatrix-web.service 2>/dev/null; then
if netstat -tuln 2>/dev/null | grep -q ":5001" || ss -tuln 2>/dev/null | grep -q ":5001"; then
check_pass "Web interface is listening on port 5001"
else
check_warn "Web service is running but port 5001 may not be listening"
fi
else
check_warn "Web service is not running (cannot check port)"
fi
echo ""
# 6. Check network connectivity
echo "=== Network Status ==="
if ping -c 1 -W 3 8.8.8.8 >/dev/null 2>&1; then
check_pass "Internet connectivity is available"
else
check_warn "Internet connectivity check failed (may be normal if WiFi is disconnected)"
fi
# Check WiFi status
if command -v nmcli >/dev/null 2>&1; then
WIFI_STATUS=$(nmcli device status 2>/dev/null | grep wlan0 || echo "")
if echo "$WIFI_STATUS" | grep -q "connected"; then
SSID=$(nmcli -t -f active,ssid dev wifi | grep '^yes:' | cut -d: -f2 | head -1)
check_pass "WiFi is connected to: $SSID"
else
check_warn "WiFi is not connected (AP mode may be active)"
fi
elif command -v iwconfig >/dev/null 2>&1; then
if iwconfig wlan0 2>/dev/null | grep -q "ESSID"; then
SSID=$(iwconfig wlan0 2>/dev/null | grep -oP 'ESSID:"\K[^"]*')
check_pass "WiFi is connected to: $SSID"
else
check_warn "WiFi connection status unknown"
fi
fi
# Check AP mode
if systemctl is-active --quiet hostapd 2>/dev/null; then
check_warn "AP mode is active (hostapd running) - SSH may be unavailable via WiFi"
elif systemctl is-active --quiet ledmatrix-wifi-monitor.service 2>/dev/null; then
# Check if AP mode might be active via WiFi manager
check_warn "WiFi monitor is running - may enable AP mode if WiFi disconnects"
fi
echo ""
# 7. Check sudoers configuration
echo "=== Sudo Configuration ==="
if [ -f "/etc/sudoers.d/ledmatrix_web" ]; then
check_pass "Web interface sudoers file exists"
else
check_warn "Web interface sudoers file is missing (web interface may require passwords)"
fi
echo ""
# 8. Check service logs for errors
echo "=== Recent Service Logs ==="
for service in "${services[@]}"; do
if systemctl list-unit-files | grep -q "$service"; then
ERRORS=$(journalctl -u "$service" --since "10 minutes ago" --no-pager 2>/dev/null | grep -i "error\|failed\|exception" | wc -l || echo "0")
if [ "$ERRORS" -gt 0 ]; then
check_warn "$service has $ERRORS error(s) in last 10 minutes (check logs)"
else
check_pass "$service logs show no recent errors"
fi
fi
done
echo ""
# Summary
echo "=========================================="
if [ "$ALL_PASSED" = true ]; then
echo -e "${GREEN}Installation verification PASSED${NC}"
echo ""
echo "Next steps:"
echo "1. Access the web interface at: http://$(hostname -I | awk '{print $1}'):5001"
echo "2. Check service status: sudo systemctl status ledmatrix.service"
echo "3. View logs: journalctl -u ledmatrix.service -f"
exit 0
else
echo -e "${YELLOW}Installation verification completed with warnings${NC}"
echo ""
echo "Some checks failed. Please review the output above."
echo "Common fixes:"
echo "- Re-run first_time_install.sh if services are missing"
echo "- Check service logs: journalctl -u <service-name> -f"
echo "- Ensure you're logged in after group changes: newgrp systemd-journal"
exit 1
fi

160
scripts/verify_web_ui.sh Executable file
View File

@@ -0,0 +1,160 @@
#!/bin/bash
# Quick Web UI Verification Script
# Run this via SSH on your Raspberry Pi to verify the web interface is running
echo "=========================================="
echo "Web UI Verification"
echo "=========================================="
echo ""
# Colors
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# 1. Check service status
echo "1. Checking service status..."
if systemctl is-active --quiet ledmatrix-web.service 2>/dev/null; then
echo -e "${GREEN}${NC} ledmatrix-web.service is running"
# Get detailed status
echo ""
echo "Service details:"
systemctl status ledmatrix-web.service --no-pager -l | head -15
else
echo -e "${RED}${NC} ledmatrix-web.service is NOT running"
echo ""
echo "To start the service:"
echo " sudo systemctl start ledmatrix-web.service"
echo ""
fi
echo ""
# 2. Check if port 5001 is listening
echo "2. Checking if port 5001 is listening..."
if command -v ss >/dev/null 2>&1; then
if ss -tuln 2>/dev/null | grep -q ":5001"; then
echo -e "${GREEN}${NC} Port 5001 is listening"
echo ""
echo "Active connections on port 5001:"
ss -tuln | grep ":5001"
else
echo -e "${RED}${NC} Port 5001 is NOT listening"
fi
elif command -v netstat >/dev/null 2>&1; then
if netstat -tuln 2>/dev/null | grep -q ":5001"; then
echo -e "${GREEN}${NC} Port 5001 is listening"
echo ""
echo "Active connections on port 5001:"
netstat -tuln | grep ":5001"
else
echo -e "${RED}${NC} Port 5001 is NOT listening"
fi
else
echo -e "${YELLOW}${NC} Cannot check port (ss/netstat not available)"
fi
echo ""
# 3. Test HTTP connection
echo "3. Testing HTTP connection..."
if curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://localhost:5001 > /dev/null 2>&1; then
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://localhost:5001 2>/dev/null)
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "302" ] || [ "$HTTP_CODE" = "301" ]; then
echo -e "${GREEN}${NC} Web interface is responding (HTTP $HTTP_CODE)"
else
echo -e "${YELLOW}${NC} Web interface responded with HTTP $HTTP_CODE"
fi
else
echo -e "${RED}${NC} Cannot connect to web interface on port 5001"
fi
echo ""
# 4. Get Pi's IP address
echo "4. Network information..."
IP_ADDRESSES=$(hostname -I 2>/dev/null | awk '{print $1}')
if [ -n "$IP_ADDRESSES" ]; then
echo "Pi IP address(es): $IP_ADDRESSES"
echo ""
echo "Access web interface at:"
for ip in $IP_ADDRESSES; do
echo " http://$ip:5001"
done
else
echo -e "${YELLOW}${NC} Could not determine IP address"
fi
echo ""
# 5. Check recent logs
echo "5. Recent service logs (last 10 lines)..."
echo "----------------------------------------"
journalctl -u ledmatrix-web.service -n 10 --no-pager 2>/dev/null || echo "Could not retrieve logs"
echo ""
# 6. Check for errors in logs
echo "6. Checking for errors in logs..."
ERROR_COUNT=$(journalctl -u ledmatrix-web.service --since "5 minutes ago" --no-pager 2>/dev/null | grep -i "error\|exception\|failed\|traceback" | wc -l)
if [ "$ERROR_COUNT" -gt 0 ]; then
echo -e "${YELLOW}${NC} Found $ERROR_COUNT error(s) in last 5 minutes"
echo ""
echo "Recent errors:"
journalctl -u ledmatrix-web.service --since "5 minutes ago" --no-pager 2>/dev/null | grep -i "error\|exception\|failed\|traceback" | tail -5
else
echo -e "${GREEN}${NC} No errors in recent logs"
fi
echo ""
# Summary
echo "=========================================="
echo "Summary"
echo "=========================================="
SERVICE_RUNNING=false
PORT_LISTENING=false
HTTP_RESPONDING=false
if systemctl is-active --quiet ledmatrix-web.service 2>/dev/null; then
SERVICE_RUNNING=true
fi
if (ss -tuln 2>/dev/null | grep -q ":5001") || (netstat -tuln 2>/dev/null | grep -q ":5001"); then
PORT_LISTENING=true
fi
if curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://localhost:5001 > /dev/null 2>&1; then
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://localhost:5001 2>/dev/null)
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "302" ] || [ "$HTTP_CODE" = "301" ]; then
HTTP_RESPONDING=true
fi
fi
if [ "$SERVICE_RUNNING" = true ] && [ "$PORT_LISTENING" = true ] && [ "$HTTP_RESPONDING" = true ]; then
echo -e "${GREEN}✓ Web UI is running correctly${NC}"
echo ""
echo "You can access it at:"
for ip in $IP_ADDRESSES; do
echo " http://$ip:5001"
done
exit 0
elif [ "$SERVICE_RUNNING" = false ]; then
echo -e "${RED}✗ Web UI service is not running${NC}"
echo ""
echo "To start it:"
echo " sudo systemctl start ledmatrix-web.service"
echo " sudo systemctl enable ledmatrix-web.service # to start on boot"
exit 1
elif [ "$PORT_LISTENING" = false ]; then
echo -e "${RED}✗ Service is running but port 5001 is not listening${NC}"
echo ""
echo "Check logs for errors:"
echo " sudo journalctl -u ledmatrix-web.service -f"
exit 1
else
echo -e "${YELLOW}⚠ Web UI may have issues${NC}"
echo ""
echo "Check logs for details:"
echo " sudo journalctl -u ledmatrix-web.service -f"
exit 1
fi

View File

@@ -0,0 +1,225 @@
#!/bin/bash
# Pre-Testing WiFi Verification Script
# Run this BEFORE disconnecting Ethernet to ensure WiFi is ready
# Don't use set -e as it can cause premature exits with arithmetic operations
# Instead, we'll check return codes explicitly where needed
set -u # Fail on undefined variables
echo "=========================================="
echo "WiFi Pre-Testing Verification"
echo "=========================================="
echo ""
echo "This script verifies WiFi is enabled and working"
echo "before you disconnect Ethernet for captive portal testing."
echo ""
# Colors
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m'
# Check counter
PASSED=0
FAILED=0
WARNINGS=0
check_result() {
local result=$1
local message=$2
if [ $result -eq 0 ]; then
echo -e "${GREEN}${NC} $message"
PASSED=$((PASSED + 1))
else
echo -e "${RED}${NC} $message"
FAILED=$((FAILED + 1))
fi
}
warn_result() {
local message=$2
echo -e "${YELLOW}${NC} $message"
WARNINGS=$((WARNINGS + 1))
}
# Check 1: WiFi interface exists
echo "1. Checking WiFi interface..."
if ip link show wlan0 > /dev/null 2>&1; then
check_result 0 "WiFi interface wlan0 exists"
else
check_result 1 "WiFi interface wlan0 NOT found"
echo " → Check if WiFi adapter is connected"
echo " → Run: lsusb (for USB WiFi) or check built-in WiFi"
exit 1
fi
# Check 2: WiFi radio is enabled
echo ""
echo "2. Checking WiFi radio status..."
WIFI_STATUS=$(nmcli radio wifi 2>/dev/null || echo "unknown")
if echo "$WIFI_STATUS" | grep -qi "enabled"; then
check_result 0 "WiFi radio is enabled"
elif echo "$WIFI_STATUS" | grep -qi "disabled"; then
check_result 1 "WiFi radio is DISABLED"
echo " → Enabling WiFi..."
sudo nmcli radio wifi on
sleep 2
if nmcli radio wifi | grep -qi "enabled"; then
check_result 0 "WiFi radio enabled successfully"
else
check_result 1 "Failed to enable WiFi radio"
exit 1
fi
else
warn_result 1 "Could not determine WiFi radio status"
fi
# Check 3: WiFi can scan for networks
echo ""
echo "3. Testing WiFi scanning capability..."
SCAN_RESULT=$(timeout 10 nmcli device wifi list 2>&1 | head -5)
if [ $? -eq 0 ] && [ -n "$SCAN_RESULT" ]; then
NETWORK_COUNT=$(echo "$SCAN_RESULT" | wc -l)
if [ "$NETWORK_COUNT" -gt 1 ]; then
check_result 0 "WiFi scanning works (found networks)"
echo " Sample networks found:"
echo "$SCAN_RESULT" | head -3 | sed 's/^/ /'
else
warn_result 1 "WiFi scanning works but no networks found"
echo " → This might be okay if you're in a remote location"
echo " → Make sure you can see networks when you need to connect"
fi
else
check_result 1 "WiFi scanning FAILED"
echo " → WiFi adapter may not be working properly"
echo " → Check: dmesg | grep -i wifi"
exit 1
fi
# Check 4: Current network connections
echo ""
echo "4. Checking current network status..."
ETH_STATUS=$(nmcli device status | grep "ethernet" | grep -v "unavailable" | head -1 || echo "")
WIFI_STATUS=$(nmcli device status | grep "wifi" | head -1 || echo "")
if echo "$ETH_STATUS" | grep -q "connected"; then
ETH_NAME=$(echo "$ETH_STATUS" | awk '{print $1}')
ETH_IP=$(ip addr show $ETH_NAME 2>/dev/null | grep "inet " | awk '{print $2}' | cut -d/ -f1 | head -1)
check_result 0 "Ethernet is connected ($ETH_NAME)"
if [ -n "$ETH_IP" ]; then
echo " Ethernet IP: $ETH_IP"
fi
else
warn_result 1 "Ethernet is NOT connected"
echo " → You may already be on WiFi only"
fi
if echo "$WIFI_STATUS" | grep -q "connected"; then
WIFI_NAME=$(echo "$WIFI_STATUS" | awk '{print $1}')
WIFI_IP=$(ip addr show $WIFI_NAME 2>/dev/null | grep "inet " | awk '{print $2}' | cut -d/ -f1 | head -1)
WIFI_SSID=$(nmcli -t -f active,ssid dev wifi | grep "^yes:" | cut -d: -f2 | head -1)
check_result 0 "WiFi is connected ($WIFI_NAME)"
if [ -n "$WIFI_SSID" ]; then
echo " Connected to: $WIFI_SSID"
fi
if [ -n "$WIFI_IP" ]; then
echo " WiFi IP: $WIFI_IP"
fi
echo ""
echo " ⚠ You are already connected via WiFi!"
echo " → You may want to disconnect WiFi first to test captive portal"
echo " → Or test from a different device"
else
if echo "$WIFI_STATUS" | grep -q "disconnected"; then
check_result 0 "WiFi is disconnected (ready for AP mode)"
else
warn_result 1 "WiFi status unclear"
fi
fi
# Check 5: Internet connectivity test
echo ""
echo "5. Testing internet connectivity..."
if ping -c 2 -W 3 8.8.8.8 > /dev/null 2>&1; then
check_result 0 "Internet connectivity working"
echo " → You have internet access via current connection"
else
warn_result 1 "No internet connectivity detected"
echo " → This might be okay if you're testing in isolation"
echo " → But you won't be able to download packages if needed"
fi
# Check 6: Saved WiFi connections
echo ""
echo "6. Checking saved WiFi connections..."
SAVED_CONNECTIONS=$(nmcli connection show | grep -i wifi | wc -l)
if [ "$SAVED_CONNECTIONS" -gt 0 ]; then
check_result 0 "Found $SAVED_CONNECTIONS saved WiFi connection(s)"
echo " Saved connections:"
nmcli connection show | grep -i wifi | awk '{print " - " $1}' | head -5
echo ""
echo " → You can reconnect using: sudo nmcli connection up <name>"
else
warn_result 1 "No saved WiFi connections found"
echo " → Make sure you know your WiFi SSID and password"
echo " → You'll need them to reconnect after testing"
fi
# Check 7: Required services
echo ""
echo "7. Checking required services..."
if systemctl is-active --quiet hostapd 2>/dev/null; then
warn_result 1 "hostapd is already running (AP mode may be active)"
else
check_result 0 "hostapd service is stopped (normal)"
fi
if systemctl is-active --quiet dnsmasq 2>/dev/null; then
warn_result 1 "dnsmasq is already running (AP mode may be active)"
else
check_result 0 "dnsmasq service is stopped (normal)"
fi
# Check 8: WiFi monitor service
echo ""
echo "8. Checking WiFi monitor service..."
if systemctl is-active --quiet ledmatrix-wifi-monitor 2>/dev/null; then
check_result 0 "WiFi monitor service is running"
else
warn_result 1 "WiFi monitor service is NOT running"
echo " → Start with: sudo systemctl start ledmatrix-wifi-monitor"
fi
# Summary
echo ""
echo "=========================================="
echo "Verification Summary"
echo "=========================================="
echo -e "${GREEN}Passed: ${PASSED}${NC}"
echo -e "${YELLOW}Warnings: ${WARNINGS}${NC}"
echo -e "${RED}Failed: ${FAILED}${NC}"
echo ""
if [ $FAILED -eq 0 ]; then
if [ $WARNINGS -eq 0 ]; then
echo -e "${GREEN}✓ All checks passed! WiFi is ready for testing.${NC}"
echo ""
echo "Next steps:"
echo "1. You can safely disconnect Ethernet"
echo "2. Enable AP mode to test captive portal"
echo "3. Use emergency_reconnect.sh if you need to reconnect"
else
echo -e "${YELLOW}⚠ Checks passed with warnings.${NC}"
echo ""
echo "WiFi appears ready, but review warnings above."
echo "You can proceed with testing, but be aware of the warnings."
fi
exit 0
else
echo -e "${RED}✗ Some checks failed. Please fix issues before testing.${NC}"
echo ""
echo "Do NOT disconnect Ethernet until all issues are resolved!"
exit 1
fi