mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
chore(deprecation): remove the 35 APIs deprecated for 3.8.0 (#708)
* chore(deprecation): remove the 35 APIs deprecated for 3.8.0 The usage scan (docs/DEPRECATIONS_3.8.md, regenerated 2026-10-01 and committed here) finds no call or override of any of them in the 46 monorepo plugins or the 8 third-party plugins plugins.json lists; the only core callers were other deprecated methods removed alongside. - CacheManager: 13 methods, plus the private helpers only has_data_changed used (_has_*_changed, _is_market_open). - DisplayManager: 7 methods, plus WEATHER_COLORS and the private _draw_sun/_cloud/_rain/_snow/_storm helpers only the icon methods used. - FontManager: 14 methods, plus size_tokens, _save_overrides and _clear_plugin_font_cache. font_overrides and _load_overrides stay: resolve_font() still applies config/font_overrides.json. performance_stats stays: get_font() keeps it and tests read it. - PluginManager.get_enabled_plugins. test_deprecation.py pins only the two 3.9.0 markers now; the scanner tests run against a stand-in core instead of the real markers. The memory-tier tests read stats through log_memory_cache_stats() and the component, and the test of the removed _clear_plugin_font_cache goes. Docs drop the removed methods' reference entries; the Deprecated APIs table becomes "Removed in 3.8.0". CHANGELOG gains a Removed section. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(deprecation): drop the test harness's copies of the removed icon methods VisualTestDisplayManager still drew weather icons that DisplayManager no longer has, so a plugin's visual tests could pass on calls that raise AttributeError on the real display. Its draw_sun/draw_cloud/draw_rain/ draw_snow/draw_weather_icon/draw_text_with_icons, WEATHER_COLORS and the private helpers go, with the tests that exercised them. The CHANGELOG's Deprecations entries no longer say nothing is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+38
-4
@@ -538,18 +538,52 @@ read any of them:
|
||||
- The 35 plugin-facing methods deprecated in 3.5.0 are now removed in 3.8.0,
|
||||
not 3.7.0: 3.7.0 shipped with all of them still in place, still warning
|
||||
"will be removed in LEDMatrix 3.7.0". The warning, the docs and
|
||||
`test/test_deprecation.py` now say 3.8.0. Nothing is removed yet.
|
||||
`test/test_deprecation.py` now say 3.8.0. They are removed in this
|
||||
release (see Removed, below).
|
||||
- New `scripts/plugin_api_usage.py` lists every `@deprecated` core method and
|
||||
scans core, the plugin monorepo and the registry's third-party plugins for
|
||||
calls and overrides, telling real uses from unrelated methods of the same
|
||||
name. Its output is `docs/DEPRECATIONS_3.8.md` (linked from
|
||||
`docs/PLUGIN_API_REFERENCE.md#deprecated-apis`): 34 of the 35 are unused;
|
||||
`CacheManager.get_memory_cache_stats` is still called by core's own
|
||||
`log_memory_cache_stats()`, so it stays until that call migrates.
|
||||
`docs/PLUGIN_API_REFERENCE.md#deprecated-apis`); no plugin uses any of
|
||||
the 35.
|
||||
- `test/test_deprecation.py` fails while any `@deprecated` marker names a
|
||||
release at or below `src.__version__`, so a release can no longer ship
|
||||
warning about a removal it has already passed.
|
||||
|
||||
### Removed
|
||||
|
||||
The 35 plugin-facing methods deprecated in 3.5.0 (each has logged a warning
|
||||
on first call since, announced for 3.7.0 and then moved to 3.8.0) are gone.
|
||||
The usage scan (`docs/DEPRECATIONS_3.8.md`, re-run 2026-10-01) found no call
|
||||
or override of any of them in the 46 monorepo plugins or the 8 third-party
|
||||
plugins `plugins.json` lists, and core's own last callers went with them. A
|
||||
plugin that still calls one gets an `AttributeError`;
|
||||
`docs/PLUGIN_API_REFERENCE.md#deprecated-apis` lists what to use instead.
|
||||
|
||||
- `CacheManager`: `has_data_changed`, `update_cache`, `setup_persistent_cache`,
|
||||
`get_sport_live_interval`, `get_sport_key_from_cache_key`,
|
||||
`get_background_cached_data`, `is_background_data_available`,
|
||||
`record_cache_hit`, `record_cache_miss`, `record_fetch_time`,
|
||||
`get_cache_metrics`, `log_cache_metrics`, `get_memory_cache_stats`. The
|
||||
private change-detection helpers behind `has_data_changed`
|
||||
(`_has_weather_changed` and friends, `_is_market_open`) went with it.
|
||||
- `DisplayManager`: `draw_weather_icon`, `draw_sun`, `draw_cloud`, `draw_rain`,
|
||||
`draw_snow`, `draw_text_with_icons`, `get_scrolling_stats`, and with them
|
||||
the `WEATHER_COLORS` table and the private `_draw_sun`/`_draw_cloud`/
|
||||
`_draw_rain`/`_draw_snow`/`_draw_storm` helpers.
|
||||
`VisualTestDisplayManager` (the plugin test harness) drops its copies of
|
||||
the icon methods too, so a plugin's visual tests fail the way the real
|
||||
display would instead of passing against methods that no longer exist.
|
||||
- `FontManager`: `set_override`, `remove_override`, `get_overrides`,
|
||||
`add_font`, `remove_font`, `validate_font`, `get_font_catalog`,
|
||||
`get_available_fonts`, `get_size_tokens`, `get_performance_stats`,
|
||||
`get_manager_fonts`, `get_detected_fonts`, `get_plugin_fonts`,
|
||||
`unregister_plugin_fonts`, plus the `size_tokens` attribute and the private
|
||||
`_save_overrides` and `_clear_plugin_font_cache`. `resolve_font()` still
|
||||
applies `config/font_overrides.json`.
|
||||
- `PluginManager.get_enabled_plugins` (check `enabled` on the entries in
|
||||
`plugin_manager.plugins`).
|
||||
|
||||
### Web UI styling: a real Tailwind build
|
||||
|
||||
- The web UI's utility classes now come from a generated
|
||||
|
||||
@@ -27,7 +27,7 @@ Advanced patterns, examples, and best practices for developing LEDMatrix plugins
|
||||
|
||||
The Display Manager's icon methods — `draw_weather_icon()`, `draw_sun()`,
|
||||
`draw_cloud()`, `draw_rain()`, `draw_snow()` and `draw_text_with_icons()` —
|
||||
are deprecated, removed in 3.8.0. Draw your own icons instead: render them
|
||||
were removed in 3.8.0. Draw your own icons instead: render them
|
||||
onto a PIL image and paste it onto `self.display_manager.image`, or ship
|
||||
icon images with the plugin. The weather plugin's `WeatherIcons` class is an
|
||||
example. See [Deprecated APIs](PLUGIN_API_REFERENCE.md#deprecated-apis).
|
||||
@@ -194,7 +194,7 @@ def update(self):
|
||||
sport_key = "nhl"
|
||||
cache_key = f"{self.plugin_id}_{sport_key}_games"
|
||||
|
||||
# get_background_cached_data() is deprecated, removed in 3.8.0 — use get()
|
||||
# get_background_cached_data() was removed in 3.8.0 — use get()
|
||||
cached = self.cache_manager.get(cache_key, max_age=60)
|
||||
|
||||
if cached:
|
||||
@@ -596,8 +596,8 @@ def update(self):
|
||||
|
||||
```python
|
||||
def update(self):
|
||||
# get_enabled_plugins() is deprecated, removed in 3.8.0 — check the
|
||||
# instance's `enabled` flag instead
|
||||
# get_enabled_plugins() was removed in 3.8.0 — check the instance's
|
||||
# `enabled` flag instead
|
||||
weather_plugin = self.plugin_manager.get_plugin("weather")
|
||||
if weather_plugin is not None and weather_plugin.enabled:
|
||||
# Use weather data
|
||||
|
||||
+22
-22
@@ -2,8 +2,8 @@
|
||||
|
||||
Generated by `scripts/plugin_api_usage.py` — do not edit by hand; re-run it (see [How to re-run](#how-to-re-run)).
|
||||
|
||||
- Scanned: 2026-09-30, core 3.7.0
|
||||
- Monorepo: [ChuckBuilds/ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins) (main @ 4327c2e4), 46 plugins
|
||||
- Scanned: 2026-10-01, core 3.7.0
|
||||
- Monorepo: [ChuckBuilds/ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins) (main @ 4de1d134), 46 plugins
|
||||
- Third-party plugins: 8 with their own repo in `plugins.json` (f1-live, gif-player, pga-tour-leaderboard, plex-marquee, ledmatrix-dresden-departures, tidbyt-baseball-scoreboard, sleeper-fantasy, ledmatrix-nascar)
|
||||
|
||||
**37 deprecated methods: 36 unused, 1 still used, 0 need review.**
|
||||
@@ -78,19 +78,19 @@ File paths are relative to the plugin's directory (core: the repo root).
|
||||
| `CacheManager.get_memory_cache_stats` | core tests | test/test_cache_manager_memory_tier.py:63 | test call | `assert cm.get_memory_cache_stats()["last_cleanup"] >= before` |
|
||||
| `CacheManager.get_memory_cache_stats` | core tests | test/test_cache_manager_memory_tier.py:68 | test call | `stats = cm.get_memory_cache_stats()` |
|
||||
| `DisplayManager.draw_sun` | core | src/plugin_system/testing/visual_display_manager.py:417 | unrelated | `def draw_sun(self, x: int, y: int, size: int = 16):` |
|
||||
| `DisplayManager.draw_cloud` | core | src/display_manager.py:1356 | internal (in `DisplayManager.draw_rain`) | `self.draw_cloud(x, y, size)` |
|
||||
| `DisplayManager.draw_cloud` | core | src/display_manager.py:1371 | internal (in `DisplayManager.draw_snow`) | `self.draw_cloud(x, y, size)` |
|
||||
| `DisplayManager.draw_cloud` | core | src/display_manager.py:1359 | internal (in `DisplayManager.draw_rain`) | `self.draw_cloud(x, y, size)` |
|
||||
| `DisplayManager.draw_cloud` | core | src/display_manager.py:1374 | internal (in `DisplayManager.draw_snow`) | `self.draw_cloud(x, y, size)` |
|
||||
| `DisplayManager.draw_cloud` | core | src/plugin_system/testing/visual_display_manager.py:421 | unrelated | `def draw_cloud(self, x: int, y: int, size: int = 16, color: Tuple[int, int, int] = (200, 200, 200)):` |
|
||||
| `DisplayManager.draw_cloud` | ledmatrix-weather | weather_icons.py:184 | unrelated | `def draw_cloud(draw: ImageDraw, x: int, y: int, size: int = 16, color: tuple = (200, 200, 200)):` |
|
||||
| `DisplayManager.draw_rain` | core | src/plugin_system/testing/visual_display_manager.py:425 | unrelated | `def draw_rain(self, x: int, y: int, size: int = 16):` |
|
||||
| `DisplayManager.draw_snow` | core | src/plugin_system/testing/visual_display_manager.py:429 | unrelated | `def draw_snow(self, x: int, y: int, size: int = 16):` |
|
||||
| `DisplayManager.draw_weather_icon` | core | src/display_manager.py:1515 | internal (in `DisplayManager.draw_text_with_icons`) | `self.draw_weather_icon(icon_type, icon_x, icon_y)` |
|
||||
| `DisplayManager.draw_weather_icon` | core | src/display_manager.py:1518 | internal (in `DisplayManager.draw_text_with_icons`) | `self.draw_weather_icon(icon_type, icon_x, icon_y)` |
|
||||
| `DisplayManager.draw_weather_icon` | core | src/plugin_system/testing/visual_display_manager.py:510 | unrelated | `def draw_weather_icon(self, condition: str, x: int, y: int, size: int = 16) -> None:` |
|
||||
| `DisplayManager.draw_weather_icon` | core | src/plugin_system/testing/visual_display_manager.py:533 | unrelated | `self.draw_weather_icon(icon_type, icon_x, icon_y)` |
|
||||
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:76 | unrelated | `def draw_weather_icon(image, icon_code, x, y, size):` |
|
||||
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:1265 | unrelated | `WeatherIcons.draw_weather_icon(img, icon_code, icon_x, icon_y,` |
|
||||
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:1544 | unrelated | `WeatherIcons.draw_weather_icon(img, forecast['icon'], icon_x, icon_y, icon_size)` |
|
||||
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:1635 | unrelated | `WeatherIcons.draw_weather_icon(img, forecast['icon'], icon_x, icon_y, icon_size)` |
|
||||
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:84 | unrelated | `def draw_weather_icon(image, icon_code, x, y, size):` |
|
||||
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:1280 | unrelated | `WeatherIcons.draw_weather_icon(img, icon_code, icon_x, icon_y,` |
|
||||
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:1559 | unrelated | `WeatherIcons.draw_weather_icon(img, forecast['icon'], icon_x, icon_y, icon_size)` |
|
||||
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:1650 | unrelated | `WeatherIcons.draw_weather_icon(img, forecast['icon'], icon_x, icon_y, icon_size)` |
|
||||
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | weather_icons.py:168 | unrelated | `def draw_weather_icon(image: Image.Image, icon_code: str, x: int, y: int, size: int = DEFAULT_SIZE):` |
|
||||
| `DisplayManager.draw_text_with_icons` | core | src/plugin_system/testing/visual_display_manager.py:526 | unrelated | `def draw_text_with_icons(self, text: str, icons: List[tuple] = None,` |
|
||||
| `FontManager.get_font_catalog` | core tests | test/test_deprecation.py:229 | test call | `assert fm.get_font_catalog() == fm.font_catalog` |
|
||||
@@ -106,12 +106,12 @@ File paths are relative to the plugin's directory (core: the repo root).
|
||||
|
||||
| Source | Group | Python files | Hits |
|
||||
|---|---|---|---|
|
||||
| core | core | 164 | 20 |
|
||||
| core tests | core-tests | 323 | 17 |
|
||||
| core | core | 172 | 20 |
|
||||
| core tests | core-tests | 347 | 17 |
|
||||
| 7-segment-clock | monorepo | 3 | 0 |
|
||||
| afl-scoreboard | monorepo | 34 | 0 |
|
||||
| baseball-scoreboard | monorepo | 60 | 0 |
|
||||
| basketball-scoreboard | monorepo | 48 | 0 |
|
||||
| afl-scoreboard | monorepo | 35 | 0 |
|
||||
| baseball-scoreboard | monorepo | 61 | 0 |
|
||||
| basketball-scoreboard | monorepo | 49 | 0 |
|
||||
| birdnet-go | monorepo | 2 | 0 |
|
||||
| blackjack | monorepo | 7 | 2 |
|
||||
| calendar | monorepo | 5 | 1 |
|
||||
@@ -121,37 +121,37 @@ File paths are relative to the plugin's directory (core: the repo root).
|
||||
| cricket-scoreboard | monorepo | 8 | 0 |
|
||||
| f1-scoreboard | monorepo | 15 | 0 |
|
||||
| fantasy-blitz | monorepo | 13 | 0 |
|
||||
| football-scoreboard | monorepo | 73 | 0 |
|
||||
| football-scoreboard | monorepo | 74 | 0 |
|
||||
| geochron | monorepo | 10 | 0 |
|
||||
| hello-world | monorepo | 2 | 0 |
|
||||
| hockey-scoreboard | monorepo | 51 | 0 |
|
||||
| hockey-scoreboard | monorepo | 52 | 0 |
|
||||
| incoming-packages | monorepo | 8 | 0 |
|
||||
| jellyfin-now-playing | monorepo | 4 | 0 |
|
||||
| lacrosse-scoreboard | monorepo | 39 | 0 |
|
||||
| lacrosse-scoreboard | monorepo | 40 | 0 |
|
||||
| ledmatrix-elections | monorepo | 12 | 0 |
|
||||
| ledmatrix-flights | monorepo | 45 | 0 |
|
||||
| ledmatrix-flights | monorepo | 48 | 0 |
|
||||
| ledmatrix-leaderboard | monorepo | 9 | 0 |
|
||||
| ledmatrix-music | monorepo | 11 | 0 |
|
||||
| ledmatrix-stocks | monorepo | 7 | 0 |
|
||||
| ledmatrix-weather | monorepo | 14 | 6 |
|
||||
| ledmatrix-weather | monorepo | 15 | 6 |
|
||||
| march-madness | monorepo | 4 | 0 |
|
||||
| masters-tournament | monorepo | 10 | 0 |
|
||||
| mqtt-notifications | monorepo | 4 | 0 |
|
||||
| news | monorepo | 6 | 0 |
|
||||
| nfl-draft | monorepo | 3 | 0 |
|
||||
| nfl-stat-leaders | monorepo | 8 | 0 |
|
||||
| nrl-scoreboard | monorepo | 29 | 0 |
|
||||
| nrl-scoreboard | monorepo | 30 | 0 |
|
||||
| odds-ticker | monorepo | 9 | 0 |
|
||||
| of-the-day | monorepo | 14 | 0 |
|
||||
| olympics | monorepo | 16 | 1 |
|
||||
| on-air | monorepo | 2 | 0 |
|
||||
| pomodoro-timer | monorepo | 3 | 0 |
|
||||
| soccer-scoreboard | monorepo | 46 | 0 |
|
||||
| soccer-scoreboard | monorepo | 47 | 0 |
|
||||
| static-image | monorepo | 3 | 0 |
|
||||
| stock-news | monorepo | 3 | 0 |
|
||||
| text-display | monorepo | 4 | 0 |
|
||||
| tide-display | monorepo | 3 | 0 |
|
||||
| ufc-scoreboard | monorepo | 34 | 0 |
|
||||
| ufc-scoreboard | monorepo | 38 | 0 |
|
||||
| web-ui-info | monorepo | 2 | 0 |
|
||||
| youtube-stats | monorepo | 5 | 0 |
|
||||
| f1-live | third-party | 10 | 0 |
|
||||
|
||||
@@ -54,8 +54,8 @@ rows = self.layout.bounds.inset(1).split_v(3, 1, gap=1)
|
||||
self.draw_fit("12:34", rows[0]) # largest crisp font that fits
|
||||
self.draw_image(logo, rows[1], mode="fill_height", crop_to_ink=True)
|
||||
|
||||
# Weather icons: draw_weather_icon() is deprecated, removed in 3.8.0 —
|
||||
# draw your own icons (the weather plugin ships WeatherIcons)
|
||||
# Weather icons: draw_weather_icon() was removed in 3.8.0 — draw your
|
||||
# own icons (the weather plugin ships WeatherIcons)
|
||||
|
||||
# Scrolling state
|
||||
display_manager.set_scrolling_state(True)
|
||||
@@ -78,7 +78,7 @@ strategy = cache_manager.get_cache_strategy("weather")
|
||||
```
|
||||
|
||||
`get_background_cached_data()` (use `get()`) and `get_sport_live_interval()`
|
||||
are deprecated, removed in 3.8.0. See
|
||||
were removed in 3.8.0. See
|
||||
[Deprecated APIs](PLUGIN_API_REFERENCE.md#deprecated-apis).
|
||||
|
||||
## Plugin Manager Quick Methods
|
||||
@@ -87,8 +87,8 @@ are deprecated, removed in 3.8.0. See
|
||||
# Get plugins
|
||||
plugin = plugin_manager.get_plugin("plugin-id")
|
||||
all_plugins = plugin_manager.get_all_plugins()
|
||||
# get_enabled_plugins() is deprecated, removed in 3.8.0 — check `enabled`
|
||||
# on the entries in plugin_manager.plugins
|
||||
# get_enabled_plugins() was removed in 3.8.0 — check `enabled` on the
|
||||
# entries in plugin_manager.plugins
|
||||
|
||||
# Get info
|
||||
info = plugin_manager.get_plugin_info("plugin-id")
|
||||
|
||||
@@ -13,10 +13,9 @@
|
||||
BDF fonts in `assets/fonts/`, registers fonts that plugins ship, and records
|
||||
which plugin uses which font so the web UI can show it.
|
||||
|
||||
Several methods are deprecated and will be removed in LEDMatrix 3.8.0; they
|
||||
log a warning on first call. They are listed in
|
||||
[Deprecated methods](#deprecated-methods) below, and the full set is pinned in
|
||||
[`test/test_deprecation.py`](../test/test_deprecation.py).
|
||||
Several methods were removed in LEDMatrix 3.8.0 after a release of
|
||||
deprecation warnings; [Removed methods](#removed-methods) below lists them
|
||||
with what to use instead.
|
||||
|
||||
## Getting the FontManager
|
||||
|
||||
@@ -128,8 +127,8 @@ font = self.font_manager.resolve_font(
|
||||
|
||||
`resolve_font()` still honours `config/font_overrides.json` (a map of
|
||||
element key to `family` and/or `size_px`), which is read once at start-up.
|
||||
The methods that edit it — `set_override()`, `remove_override()`,
|
||||
`get_overrides()` — are deprecated, and there is no web UI or REST endpoint
|
||||
The methods that edited it — `set_override()`, `remove_override()`,
|
||||
`get_overrides()` — were removed in 3.8.0, and there is no web UI or REST endpoint
|
||||
for overrides (the override editor and `/api/v3/fonts/overrides` were
|
||||
removed). To let users choose a font, add a field to your plugin's config
|
||||
schema.
|
||||
@@ -207,9 +206,10 @@ Current methods:
|
||||
| `clear_cache()` | Drop cached fonts and metrics |
|
||||
| `font_catalog` (attribute) | Family name → file path |
|
||||
|
||||
### Deprecated methods
|
||||
### Removed methods
|
||||
|
||||
Removed in 3.8.0. Each logs a warning on first call.
|
||||
Removed in 3.8.0, after logging a deprecation warning on first call since
|
||||
3.5.0.
|
||||
|
||||
| Method | Use instead |
|
||||
|---|---|
|
||||
|
||||
+13
-114
@@ -628,18 +628,6 @@ self.display_manager.update_display()
|
||||
|
||||
This is the canonical way to render arbitrary images.
|
||||
|
||||
### Weather Icons (deprecated)
|
||||
|
||||
> Deprecated, removed in 3.8.0 — draw your own icons (the weather plugin
|
||||
> ships `WeatherIcons`). See [Deprecated APIs](#deprecated-apis).
|
||||
|
||||
- `draw_weather_icon(condition, x, y, size=16)` — icon for a condition
|
||||
string such as `"clear"`, `"clouds"`, `"rain"`, `"snow"`, `"storm"`
|
||||
- `draw_sun(x, y, size=16)`, `draw_cloud(x, y, size=16, color=(200, 200, 200))`,
|
||||
`draw_rain(x, y, size=16)`, `draw_snow(x, y, size=16)`
|
||||
- `draw_text_with_icons(text, icons=None, x=None, y=None, color=(255, 255, 255))`
|
||||
— text plus a list of `(icon_type, x, y)` icons; calls `update_display()`
|
||||
|
||||
### Scrolling State Management
|
||||
|
||||
For plugins that implement scrolling content, use these methods to coordinate with the display system.
|
||||
@@ -730,20 +718,6 @@ Process any deferred updates if not currently scrolling. Called automatically by
|
||||
|
||||
**Note**: Plugins typically don't need to call this directly.
|
||||
|
||||
#### `get_scrolling_stats() -> dict`
|
||||
|
||||
> Deprecated, removed in 3.8.0. See [Deprecated APIs](#deprecated-apis).
|
||||
|
||||
Get current scrolling statistics for debugging.
|
||||
|
||||
**Returns**: Dictionary with scrolling state information
|
||||
|
||||
**Example**:
|
||||
```python
|
||||
stats = self.display_manager.get_scrolling_stats()
|
||||
self.logger.debug(f"Scrolling: {stats['is_scrolling']}, Deferred: {stats['deferred_count']}")
|
||||
```
|
||||
|
||||
### Available Fonts
|
||||
|
||||
The Display Manager provides several pre-loaded fonts:
|
||||
@@ -873,27 +847,6 @@ Get data with automatic strategy detection from cache key.
|
||||
data = self.cache_manager.get_with_auto_strategy("nhl_live_scores")
|
||||
```
|
||||
|
||||
#### `get_background_cached_data(key: str, sport_key: Optional[str] = None) -> Optional[Dict[str, Any]]`
|
||||
|
||||
> Deprecated, removed in 3.8.0 — use `get()`. See [Deprecated APIs](#deprecated-apis).
|
||||
|
||||
Get background service cached data with sport-specific intervals.
|
||||
|
||||
**Parameters**:
|
||||
- `key` (str): Cache key
|
||||
- `sport_key` (str, optional): Sport identifier (e.g., 'nhl', 'nba') for live interval lookup
|
||||
|
||||
**Returns**: Cached data, or `None` if not found or stale
|
||||
|
||||
**Example**:
|
||||
```python
|
||||
# Uses sport-specific live_update_interval from config
|
||||
games = self.cache_manager.get_background_cached_data(
|
||||
"nhl_games",
|
||||
sport_key="nhl"
|
||||
)
|
||||
```
|
||||
|
||||
### Strategy Methods
|
||||
|
||||
#### `get_cache_strategy(data_type: str, sport_key: Optional[str] = None) -> Dict[str, Any]`
|
||||
@@ -912,23 +865,6 @@ strategy = self.cache_manager.get_cache_strategy("sports_live", sport_key="nhl")
|
||||
max_age = strategy['max_age'] # Get configured max age
|
||||
```
|
||||
|
||||
#### `get_sport_live_interval(sport_key: str) -> int`
|
||||
|
||||
> Deprecated, removed in 3.8.0. See [Deprecated APIs](#deprecated-apis).
|
||||
|
||||
Get the live_update_interval for a specific sport from config.
|
||||
|
||||
**Parameters**:
|
||||
- `sport_key` (str): Sport identifier (e.g., 'nhl', 'nba')
|
||||
|
||||
**Returns**: Live update interval in seconds
|
||||
|
||||
**Example**:
|
||||
```python
|
||||
interval = self.cache_manager.get_sport_live_interval("nhl")
|
||||
# Returns configured live_update_interval for NHL
|
||||
```
|
||||
|
||||
#### `get_data_type_from_key(key: str) -> str`
|
||||
|
||||
Extract data type from cache key to determine appropriate cache strategy.
|
||||
@@ -938,17 +874,6 @@ Extract data type from cache key to determine appropriate cache strategy.
|
||||
|
||||
**Returns**: Inferred data type string
|
||||
|
||||
#### `get_sport_key_from_cache_key(key: str) -> Optional[str]`
|
||||
|
||||
> Deprecated, removed in 3.8.0. See [Deprecated APIs](#deprecated-apis).
|
||||
|
||||
Extract sport key from cache key for sport-specific strategies.
|
||||
|
||||
**Parameters**:
|
||||
- `key` (str): Cache key
|
||||
|
||||
**Returns**: Sport identifier, or `None` if not found
|
||||
|
||||
### Utility Methods
|
||||
|
||||
#### `clear_cache(key: Optional[str] = None) -> None`
|
||||
@@ -986,30 +911,6 @@ for file_info in files:
|
||||
self.logger.info(f"Cache: {file_info['key']}, Age: {file_info['age_display']}")
|
||||
```
|
||||
|
||||
### Metrics Methods (deprecated)
|
||||
|
||||
#### `get_cache_metrics() -> Dict[str, Any]`
|
||||
|
||||
> Deprecated, removed in 3.8.0. See [Deprecated APIs](#deprecated-apis).
|
||||
|
||||
Get cache performance metrics.
|
||||
|
||||
**Returns**: Dictionary with cache statistics (`total_requests`, `cache_hit_rate`, `background_hit_rate`, `api_calls_saved`, `average_fetch_time`, etc.)
|
||||
|
||||
**Example**:
|
||||
```python
|
||||
metrics = self.cache_manager.get_cache_metrics()
|
||||
self.logger.info(f"Cache hit rate: {metrics['cache_hit_rate']:.2%}")
|
||||
```
|
||||
|
||||
#### `get_memory_cache_stats() -> Dict[str, Any]`
|
||||
|
||||
> Deprecated, removed in 3.8.0. See [Deprecated APIs](#deprecated-apis).
|
||||
|
||||
Get memory cache statistics.
|
||||
|
||||
**Returns**: Dictionary with memory cache stats (size, max_size, etc.)
|
||||
|
||||
---
|
||||
|
||||
## Plugin Manager
|
||||
@@ -1048,14 +949,6 @@ for plugin_id, plugin in all_plugins.items():
|
||||
self.logger.info(f"Plugin {plugin_id} is loaded")
|
||||
```
|
||||
|
||||
#### `get_enabled_plugins() -> List[str]`
|
||||
|
||||
> Deprecated, removed in 3.8.0 — check `enabled` on the instances in `plugin_manager.plugins`. See [Deprecated APIs](#deprecated-apis).
|
||||
|
||||
Get list of enabled plugin IDs.
|
||||
|
||||
**Returns**: List of plugin identifier strings
|
||||
|
||||
#### `get_plugin_info(plugin_id: str) -> Optional[Dict[str, Any]]`
|
||||
|
||||
Get plugin information including manifest and runtime info.
|
||||
@@ -1221,13 +1114,19 @@ if weather is not None and weather.enabled:
|
||||
|
||||
## Deprecated APIs
|
||||
|
||||
These still work but log a warning the first time they are called
|
||||
(`journalctl -u ledmatrix` shows which one), and are **removed in 3.8.0**
|
||||
(first announced for 3.7.0, which shipped with them still in place).
|
||||
[DEPRECATIONS_3.8.md](DEPRECATIONS_3.8.md) is the usage scan behind that
|
||||
decision: which of these the official plugins, the registry's third-party
|
||||
plugins and core still call or override. Only methods that scan reports unused
|
||||
are removed in 3.8.0; the rest stay until their callers migrate.
|
||||
A deprecated method still works but logs a warning the first time it is
|
||||
called (`journalctl -u ledmatrix` shows which one), until the release that
|
||||
removes it. [DEPRECATIONS_3.8.md](DEPRECATIONS_3.8.md) is the usage scan
|
||||
behind each removal: which of the deprecated methods the official plugins,
|
||||
the registry's third-party plugins and core still call or override. Only
|
||||
methods that scan reports unused are removed; the rest stay until their
|
||||
callers migrate.
|
||||
|
||||
### Removed in 3.8.0
|
||||
|
||||
Deprecated in 3.5.0 with a warning on first call, and gone in 3.8.0:
|
||||
the scan found no caller in any official or third-party plugin. Calling one
|
||||
now raises `AttributeError`.
|
||||
|
||||
| Object | Methods | Instead |
|
||||
|---|---|---|
|
||||
|
||||
@@ -519,15 +519,12 @@ When developing plugins, you'll need to use the APIs provided by the LEDMatrix s
|
||||
- `draw_text()` - Text rendering. For images, paste directly onto
|
||||
`display_manager.image` (a PIL Image) and call `update_display()`;
|
||||
there is no `draw_image()` helper method.
|
||||
- `draw_weather_icon()`, `draw_sun()`, `draw_cloud()` - Weather icons
|
||||
(deprecated, removed in 3.8.0 — draw your own icons)
|
||||
- `get_text_width()`, `get_font_height()` - Text utilities
|
||||
- `set_scrolling_state()`, `defer_update()` - Scrolling state management
|
||||
|
||||
**Cache Manager** (`self.cache_manager`):
|
||||
- `get()`, `set()`, `delete()` - Basic caching
|
||||
- `get_cached_data_with_strategy()` - Advanced caching with strategies
|
||||
- `get_background_cached_data()` - deprecated, removed in 3.8.0 — use `get()`
|
||||
|
||||
**Plugin Manager** (`self.plugin_manager`):
|
||||
- `get_plugin()`, `get_all_plugins()` - Access other plugins
|
||||
|
||||
@@ -37,7 +37,6 @@ from src.cache.disk_cache import DiskCache
|
||||
from src.cache.cache_strategy import CacheStrategy
|
||||
from src.cache.cache_metrics import CacheMetrics
|
||||
from src.logging_config import get_logger
|
||||
from src.deprecation import deprecated
|
||||
|
||||
# Canonical implementation lives in src.cache.disk_cache; re-exported here
|
||||
# because this module's docstring documents it and external code may import
|
||||
@@ -408,122 +407,6 @@ class CacheManager:
|
||||
"""Get the cache directory path."""
|
||||
return self.cache_dir
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def has_data_changed(self, data_type: str, new_data: Dict[str, Any]) -> bool:
|
||||
"""Check if data has changed from cached version."""
|
||||
cached_data = self.load_cache(data_type)
|
||||
if not cached_data:
|
||||
return True
|
||||
|
||||
if data_type == 'weather':
|
||||
return self._has_weather_changed(cached_data, new_data)
|
||||
elif data_type == 'stocks':
|
||||
return self._has_stocks_changed(cached_data, new_data)
|
||||
elif data_type == 'stock_news':
|
||||
return self._has_news_changed(cached_data, new_data)
|
||||
elif data_type == 'nhl':
|
||||
return self._has_nhl_changed(cached_data, new_data)
|
||||
elif data_type == 'mlb':
|
||||
return self._has_mlb_changed(cached_data, new_data)
|
||||
|
||||
return True
|
||||
|
||||
def _has_weather_changed(self, cached: Dict[str, Any], new: Dict[str, Any]) -> bool:
|
||||
"""Check if weather data has changed."""
|
||||
# Handle new cache structure where data is nested under 'data' key
|
||||
if 'data' in cached:
|
||||
cached = cached['data']
|
||||
|
||||
# Handle case where cached data might be the weather data directly
|
||||
if 'current' in cached:
|
||||
# This is the new structure with 'current' and 'forecast' keys
|
||||
current_weather = cached.get('current', {})
|
||||
if current_weather and 'main' in current_weather and 'weather' in current_weather:
|
||||
cached_temp = round(current_weather['main']['temp'])
|
||||
cached_condition = current_weather['weather'][0]['main']
|
||||
return (cached_temp != new.get('temp') or
|
||||
cached_condition != new.get('condition'))
|
||||
|
||||
# Handle old structure where temp and condition are directly accessible
|
||||
return (cached.get('temp') != new.get('temp') or
|
||||
cached.get('condition') != new.get('condition'))
|
||||
|
||||
def _has_stocks_changed(self, cached: Dict[str, Any], new: Dict[str, Any]) -> bool:
|
||||
"""Check if stock data has changed."""
|
||||
if not self._is_market_open():
|
||||
return False
|
||||
return cached.get('price') != new.get('price')
|
||||
|
||||
def _has_news_changed(self, cached: Dict[str, Any], new: Dict[str, Any]) -> bool:
|
||||
"""Check if news data has changed."""
|
||||
# Handle both dictionary and list formats
|
||||
if isinstance(new, list):
|
||||
# If new data is a list, cached data should also be a list
|
||||
if not isinstance(cached, list):
|
||||
return True
|
||||
# Compare lengths and content
|
||||
if len(cached) != len(new):
|
||||
return True
|
||||
# Compare titles since they're unique enough for our purposes
|
||||
cached_titles = set(item.get('title', '') for item in cached)
|
||||
new_titles = set(item.get('title', '') for item in new)
|
||||
return cached_titles != new_titles
|
||||
else:
|
||||
# Original dictionary format handling
|
||||
cached_headlines = set(h.get('id') for h in cached.get('headlines', []))
|
||||
new_headlines = set(h.get('id') for h in new.get('headlines', []))
|
||||
return not cached_headlines.issuperset(new_headlines)
|
||||
|
||||
def _has_nhl_changed(self, cached: Dict[str, Any], new: Dict[str, Any]) -> bool:
|
||||
"""Check if NHL data has changed."""
|
||||
return (cached.get('game_status') != new.get('game_status') or
|
||||
cached.get('score') != new.get('score'))
|
||||
|
||||
def _has_mlb_changed(self, cached: Dict[str, Any], new: Dict[str, Any]) -> bool:
|
||||
"""Check if MLB game data has changed."""
|
||||
if not cached or not new:
|
||||
return True
|
||||
|
||||
# Check if any games have changed status or score
|
||||
for game_id, new_game in new.items():
|
||||
cached_game = cached.get(game_id)
|
||||
if not cached_game:
|
||||
return True
|
||||
|
||||
# Check for score changes
|
||||
if (new_game['away_score'] != cached_game['away_score'] or
|
||||
new_game['home_score'] != cached_game['home_score']):
|
||||
return True
|
||||
|
||||
# Check for status changes
|
||||
if new_game['status'] != cached_game['status']:
|
||||
return True
|
||||
|
||||
# For live games, check inning and count
|
||||
if new_game['status'] == 'in':
|
||||
if (new_game['inning'] != cached_game['inning'] or
|
||||
new_game['inning_half'] != cached_game['inning_half'] or
|
||||
new_game['balls'] != cached_game['balls'] or
|
||||
new_game['strikes'] != cached_game['strikes'] or
|
||||
new_game['bases_occupied'] != cached_game['bases_occupied']):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _is_market_open(self) -> bool:
|
||||
"""Check if the US stock market is currently open."""
|
||||
return self._strategy_component.is_market_open()
|
||||
|
||||
@deprecated("3.8.0", "use set()")
|
||||
def update_cache(self, data_type: str, data: Dict[str, Any]) -> bool:
|
||||
"""Update cache with new data."""
|
||||
cache_data = {
|
||||
# Header first; see DiskCache's stale check.
|
||||
'timestamp': time.time(),
|
||||
'data': data,
|
||||
}
|
||||
return self.save_cache(data_type, cache_data)
|
||||
|
||||
def get(self, key: str, max_age: Optional[int] = 300,
|
||||
memory_ttl: Optional[int] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Get data from cache if it exists and is not stale.
|
||||
@@ -564,42 +447,6 @@ class CacheManager:
|
||||
cache_data['data'] = data
|
||||
self.save_cache(key, cache_data)
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def setup_persistent_cache(self) -> bool:
|
||||
"""
|
||||
Set up a persistent cache directory with proper permissions.
|
||||
This should be run once with sudo to create the directory.
|
||||
"""
|
||||
try:
|
||||
# Try to create /var/cache/ledmatrix with proper permissions
|
||||
from pathlib import Path
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
get_cache_dir_mode
|
||||
)
|
||||
cache_dir = '/var/cache/ledmatrix'
|
||||
cache_dir_path = Path(cache_dir)
|
||||
ensure_directory_permissions(cache_dir_path, get_cache_dir_mode())
|
||||
|
||||
# Set ownership to the real user (not root)
|
||||
real_user = os.environ.get('SUDO_USER')
|
||||
if real_user:
|
||||
import pwd
|
||||
try:
|
||||
uid = pwd.getpwnam(real_user).pw_uid
|
||||
gid = pwd.getpwnam(real_user).pw_gid
|
||||
os.chown(cache_dir, uid, gid)
|
||||
self.logger.info(f"Set ownership of {cache_dir} to {real_user}")
|
||||
except (OSError, KeyError) as e:
|
||||
self.logger.warning(f"Could not set ownership for {cache_dir}: {e}", exc_info=True)
|
||||
|
||||
self.logger.info(f"Successfully set up persistent cache directory: {cache_dir}")
|
||||
return True
|
||||
|
||||
except (OSError, IOError, PermissionError) as e:
|
||||
self.logger.error(f"Failed to set up persistent cache directory {cache_dir}: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
def cleanup_disk_cache(self, force: bool = False) -> Dict[str, Any]:
|
||||
"""
|
||||
Clean up expired disk cache files based on retention policies.
|
||||
@@ -776,14 +623,6 @@ class CacheManager:
|
||||
else:
|
||||
self.logger.info("Disk cache cleanup thread stopped successfully")
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def get_sport_live_interval(self, sport_key: str) -> int:
|
||||
"""
|
||||
Get the live_update_interval for a specific sport from config.
|
||||
Falls back to default values if config is not available.
|
||||
"""
|
||||
return self._strategy_component.get_sport_live_interval(sport_key)
|
||||
|
||||
def get_cache_strategy(self, data_type: str, sport_key: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Get cache strategy for different data types.
|
||||
@@ -798,13 +637,6 @@ class CacheManager:
|
||||
"""
|
||||
return self._strategy_component.get_data_type_from_key(key)
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def get_sport_key_from_cache_key(self, key: str) -> Optional[str]:
|
||||
"""
|
||||
Extract sport key from cache key to determine appropriate live_update_interval.
|
||||
"""
|
||||
return self._strategy_component.get_sport_key_from_cache_key(key)
|
||||
|
||||
def get_cached_data_with_strategy(self, key: str, data_type: str = 'default') -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get data from cache using data-type-specific strategy.
|
||||
@@ -838,58 +670,6 @@ class CacheManager:
|
||||
data_type = self.get_data_type_from_key(key)
|
||||
return self.get_cached_data_with_strategy(key, data_type)
|
||||
|
||||
@deprecated("3.8.0", "use get()")
|
||||
def get_background_cached_data(self, key: str, sport_key: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get data from background service cache with appropriate strategy.
|
||||
This method is specifically designed for Recent/Upcoming managers
|
||||
to use data cached by the background service.
|
||||
|
||||
Args:
|
||||
key: Cache key to retrieve
|
||||
sport_key: Sport key for determining appropriate cache strategy
|
||||
|
||||
Returns:
|
||||
Cached data if available and fresh, None otherwise
|
||||
"""
|
||||
# Determine the appropriate cache strategy
|
||||
data_type = self.get_data_type_from_key(key)
|
||||
strategy = self.get_cache_strategy(data_type, sport_key)
|
||||
|
||||
# For Recent/Upcoming managers, we want to use the background service cache
|
||||
# which should have longer TTLs than the individual manager caches
|
||||
max_age = strategy['max_age']
|
||||
memory_ttl = strategy.get('memory_ttl', max_age)
|
||||
|
||||
# Get the cached data
|
||||
cached_data = self.get_cached_data(key, max_age, memory_ttl)
|
||||
|
||||
if cached_data:
|
||||
# Record cache hit for performance monitoring
|
||||
self.record_cache_hit('background')
|
||||
# Unwrap if stored in { 'data': ..., 'timestamp': ... } format
|
||||
if isinstance(cached_data, dict) and 'data' in cached_data:
|
||||
return cached_data['data']
|
||||
return cached_data
|
||||
|
||||
# Record cache miss for performance monitoring
|
||||
self.record_cache_miss('background')
|
||||
return None
|
||||
|
||||
@deprecated("3.8.0", "use get()")
|
||||
def is_background_data_available(self, key: str, sport_key: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Check if background service has fresh data available.
|
||||
This helps Recent/Upcoming managers determine if they should
|
||||
wait for background data or fetch immediately.
|
||||
"""
|
||||
data_type = self.get_data_type_from_key(key)
|
||||
strategy = self.get_cache_strategy(data_type, sport_key)
|
||||
|
||||
# Check if we have data that's still fresh according to background service TTL
|
||||
cached_data = self.get_cached_data(key, strategy['max_age'])
|
||||
return cached_data is not None
|
||||
|
||||
def generate_sport_cache_key(self, sport: str, date_str: Optional[str] = None) -> str:
|
||||
"""
|
||||
Centralized cache key generation for sports data.
|
||||
@@ -906,45 +686,8 @@ class CacheManager:
|
||||
date_str = datetime.now(pytz.utc).strftime('%Y%m%d')
|
||||
return f"{sport}_{date_str}"
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def record_cache_hit(self, cache_type: str = 'regular') -> None:
|
||||
"""Record a cache hit for performance monitoring."""
|
||||
self._metrics_component.record_hit(cache_type)
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def record_cache_miss(self, cache_type: str = 'regular') -> None:
|
||||
"""Record a cache miss for performance monitoring."""
|
||||
self._metrics_component.record_miss(cache_type)
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def record_fetch_time(self, duration: float) -> None:
|
||||
"""Record fetch operation duration for performance monitoring."""
|
||||
self._metrics_component.record_fetch_time(duration)
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def get_cache_metrics(self) -> Dict[str, Any]:
|
||||
"""Get current cache performance metrics."""
|
||||
return self._metrics_component.get_metrics()
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def log_cache_metrics(self) -> None:
|
||||
"""Log current cache performance metrics."""
|
||||
self._metrics_component.log_metrics()
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def get_memory_cache_stats(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get statistics about the memory cache.
|
||||
|
||||
Returns:
|
||||
Dictionary with memory cache statistics
|
||||
"""
|
||||
return self._memory_cache_component.get_stats()
|
||||
|
||||
def log_memory_cache_stats(self) -> None:
|
||||
"""Log current memory cache statistics."""
|
||||
# Not get_memory_cache_stats(): that is deprecated, and core must not
|
||||
# trip its own deprecation warning every time memory logging runs.
|
||||
stats = self._memory_cache_component.get_stats()
|
||||
self.logger.info(f"Memory Cache - Size: {stats['size']}/{stats['max_size']} "
|
||||
f"({stats['usage_percent']:.1f}%), "
|
||||
|
||||
+1
-212
@@ -51,8 +51,7 @@ from src.pi5_matrix_support import is_raspberry_pi_5
|
||||
import threading
|
||||
import time
|
||||
from collections import OrderedDict, deque
|
||||
from typing import Dict, Any, List, Optional, Tuple, TYPE_CHECKING
|
||||
import math
|
||||
from typing import Dict, Any, Optional, Tuple, TYPE_CHECKING
|
||||
import zlib
|
||||
import freetype
|
||||
|
||||
@@ -62,7 +61,6 @@ from src.common.frame_timing import FrameTimingRecorder
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.common.render_gate import RenderGate
|
||||
from src.deprecation import deprecated
|
||||
from src.logging_config import get_logger
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
@@ -1323,203 +1321,6 @@ class DisplayManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Error drawing text: {e}", exc_info=True)
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def draw_sun(self, x: int, y: int, size: int = 16):
|
||||
"""Draw a sun icon using yellow circles and lines."""
|
||||
center = (x + size//2, y + size//2)
|
||||
radius = size//3
|
||||
|
||||
# Draw the center circle
|
||||
self.draw.ellipse([center[0]-radius, center[1]-radius,
|
||||
center[0]+radius, center[1]+radius],
|
||||
fill=(255, 255, 0)) # Yellow
|
||||
|
||||
# Draw the rays
|
||||
ray_length = size//4
|
||||
for angle in range(0, 360, 45):
|
||||
rad = math.radians(angle)
|
||||
start_x = center[0] + (radius * math.cos(rad))
|
||||
start_y = center[1] + (radius * math.sin(rad))
|
||||
end_x = center[0] + ((radius + ray_length) * math.cos(rad))
|
||||
end_y = center[1] + ((radius + ray_length) * math.sin(rad))
|
||||
self.draw.line([start_x, start_y, end_x, end_y], fill=(255, 255, 0), width=2)
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def draw_cloud(self, x: int, y: int, size: int = 16, color=(200, 200, 200)):
|
||||
"""Draw a cloud icon."""
|
||||
# Draw multiple circles to form a cloud shape
|
||||
self.draw.ellipse([x+size//4, y+size//3, x+size//4+size//2, y+size//3+size//2], fill=color)
|
||||
self.draw.ellipse([x+size//2, y+size//3, x+size//2+size//2, y+size//3+size//2], fill=color)
|
||||
self.draw.ellipse([x+size//3, y+size//6, x+size//3+size//2, y+size//6+size//2], fill=color)
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def draw_rain(self, x: int, y: int, size: int = 16):
|
||||
"""Draw rain icon with cloud and droplets."""
|
||||
# Draw cloud
|
||||
self.draw_cloud(x, y, size)
|
||||
|
||||
# Draw rain drops
|
||||
drop_color = (0, 0, 255) # Blue
|
||||
drop_size = size//6
|
||||
for i in range(3):
|
||||
drop_x = x + size//4 + (i * size//3)
|
||||
drop_y = y + size//2
|
||||
self.draw.line([drop_x, drop_y, drop_x, drop_y+drop_size],
|
||||
fill=drop_color, width=2)
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def draw_snow(self, x: int, y: int, size: int = 16):
|
||||
"""Draw snow icon with cloud and snowflakes."""
|
||||
# Draw cloud
|
||||
self.draw_cloud(x, y, size)
|
||||
|
||||
# Draw snowflakes
|
||||
snow_color = (200, 200, 255) # Light blue
|
||||
for i in range(3):
|
||||
center_x = x + size//4 + (i * size//3)
|
||||
center_y = y + size//2 + size//4
|
||||
# Draw a small star shape
|
||||
for angle in range(0, 360, 60):
|
||||
rad = math.radians(angle)
|
||||
end_x = center_x + (size//8 * math.cos(rad))
|
||||
end_y = center_y + (size//8 * math.sin(rad))
|
||||
self.draw.line([center_x, center_y, end_x, end_y],
|
||||
fill=snow_color, width=1)
|
||||
|
||||
# Weather icon color constants
|
||||
WEATHER_COLORS = {
|
||||
'sun': (255, 200, 0), # Bright yellow
|
||||
'cloud': (200, 200, 200), # Light gray
|
||||
'rain': (0, 100, 255), # Light blue
|
||||
'snow': (220, 220, 255), # Ice blue
|
||||
'storm': (255, 255, 0) # Lightning yellow
|
||||
}
|
||||
|
||||
def _draw_sun(self, x: int, y: int, size: int) -> None:
|
||||
"""Draw a sun icon with rays."""
|
||||
center_x, center_y = x + size//2, y + size//2
|
||||
radius = size//4
|
||||
ray_length = size//3
|
||||
|
||||
# Draw the main sun circle
|
||||
self.draw.ellipse([center_x - radius, center_y - radius,
|
||||
center_x + radius, center_y + radius],
|
||||
fill=self.WEATHER_COLORS['sun'])
|
||||
|
||||
# Draw sun rays
|
||||
for angle in range(0, 360, 45):
|
||||
rad = math.radians(angle)
|
||||
start_x = center_x + int((radius + 2) * math.cos(rad))
|
||||
start_y = center_y + int((radius + 2) * math.sin(rad))
|
||||
end_x = center_x + int((radius + ray_length) * math.cos(rad))
|
||||
end_y = center_y + int((radius + ray_length) * math.sin(rad))
|
||||
self.draw.line([start_x, start_y, end_x, end_y],
|
||||
fill=self.WEATHER_COLORS['sun'], width=2)
|
||||
|
||||
def _draw_cloud(self, x: int, y: int, size: int) -> None:
|
||||
"""Draw a cloud using multiple circles."""
|
||||
cloud_color = self.WEATHER_COLORS['cloud']
|
||||
base_y = y + size//2
|
||||
|
||||
# Draw main cloud body (3 overlapping circles)
|
||||
circle_radius = size//4
|
||||
positions = [
|
||||
(x + size//3, base_y), # Left circle
|
||||
(x + size//2, base_y - size//6), # Top circle
|
||||
(x + 2*size//3, base_y) # Right circle
|
||||
]
|
||||
|
||||
for cx, cy in positions:
|
||||
self.draw.ellipse([cx - circle_radius, cy - circle_radius,
|
||||
cx + circle_radius, cy + circle_radius],
|
||||
fill=cloud_color)
|
||||
|
||||
def _draw_rain(self, x: int, y: int, size: int) -> None:
|
||||
"""Draw rain drops falling from a cloud."""
|
||||
self._draw_cloud(x, y, size)
|
||||
rain_color = self.WEATHER_COLORS['rain']
|
||||
|
||||
# Draw rain drops at an angle
|
||||
drop_size = size//8
|
||||
drops = [
|
||||
(x + size//4, y + 2*size//3),
|
||||
(x + size//2, y + 3*size//4),
|
||||
(x + 3*size//4, y + 2*size//3)
|
||||
]
|
||||
|
||||
for dx, dy in drops:
|
||||
# Draw angled rain drops
|
||||
self.draw.line([dx, dy, dx - drop_size//2, dy + drop_size],
|
||||
fill=rain_color, width=2)
|
||||
|
||||
def _draw_snow(self, x: int, y: int, size: int) -> None:
|
||||
"""Draw snowflakes falling from a cloud."""
|
||||
self._draw_cloud(x, y, size)
|
||||
snow_color = self.WEATHER_COLORS['snow']
|
||||
|
||||
# Draw snowflakes
|
||||
flake_size = size//6
|
||||
flakes = [
|
||||
(x + size//4, y + 2*size//3),
|
||||
(x + size//2, y + 3*size//4),
|
||||
(x + 3*size//4, y + 2*size//3)
|
||||
]
|
||||
|
||||
for fx, fy in flakes:
|
||||
# Draw a snowflake (six-pointed star)
|
||||
for angle in range(0, 360, 60):
|
||||
rad = math.radians(angle)
|
||||
end_x = fx + int(flake_size * math.cos(rad))
|
||||
end_y = fy + int(flake_size * math.sin(rad))
|
||||
self.draw.line([fx, fy, end_x, end_y],
|
||||
fill=snow_color, width=1)
|
||||
|
||||
def _draw_storm(self, x: int, y: int, size: int) -> None:
|
||||
"""Draw a storm cloud with lightning bolt."""
|
||||
self._draw_cloud(x, y, size)
|
||||
|
||||
# Draw lightning bolt
|
||||
bolt_color = self.WEATHER_COLORS['storm']
|
||||
bolt_points = [
|
||||
(x + size//2, y + size//2), # Top
|
||||
(x + 3*size//5, y + 2*size//3), # Middle right
|
||||
(x + 2*size//5, y + 2*size//3), # Middle left
|
||||
(x + size//2, y + 5*size//6) # Bottom
|
||||
]
|
||||
self.draw.polygon(bolt_points, fill=bolt_color)
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def draw_weather_icon(self, condition: str, x: int, y: int, size: int = 16) -> None:
|
||||
"""Draw a weather icon based on the condition."""
|
||||
if condition.lower() in ['clear', 'sunny']:
|
||||
self._draw_sun(x, y, size)
|
||||
elif condition.lower() in ['clouds', 'cloudy', 'partly cloudy']:
|
||||
self._draw_cloud(x, y, size)
|
||||
elif condition.lower() in ['rain', 'drizzle', 'shower']:
|
||||
self._draw_rain(x, y, size)
|
||||
elif condition.lower() in ['snow', 'sleet', 'hail']:
|
||||
self._draw_snow(x, y, size)
|
||||
elif condition.lower() in ['thunderstorm', 'storm']:
|
||||
self._draw_storm(x, y, size)
|
||||
else:
|
||||
self._draw_sun(x, y, size)
|
||||
# Note: No update_display() here - let the caller handle the update
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def draw_text_with_icons(self, text: str, icons: List[tuple] = None, x: int = None, y: int = None,
|
||||
color: tuple = (255, 255, 255)):
|
||||
"""Draw text with weather icons at specified positions."""
|
||||
# Draw the text
|
||||
self.draw_text(text, x, y, color)
|
||||
|
||||
# Draw any icons
|
||||
if icons:
|
||||
for icon_type, icon_x, icon_y in icons:
|
||||
self.draw_weather_icon(icon_type, icon_x, icon_y)
|
||||
|
||||
# Update the display once after everything is drawn
|
||||
self.update_display()
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up resources."""
|
||||
if hasattr(self, '_snapshot_cond'):
|
||||
@@ -1831,18 +1632,6 @@ class DisplayManager:
|
||||
if removed_count > 0:
|
||||
logger.debug(f"Cleaned up {removed_count} expired deferred updates")
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def get_scrolling_stats(self) -> dict:
|
||||
"""Get current scrolling statistics for debugging."""
|
||||
return {
|
||||
'is_scrolling': self._scrolling_state['is_scrolling'],
|
||||
'last_activity': self._scrolling_state['last_scroll_activity'],
|
||||
'deferred_count': len(self._scrolling_state['deferred_updates']),
|
||||
'inactivity_threshold': self._scrolling_state['scroll_inactivity_threshold'],
|
||||
'max_deferred_updates': self._scrolling_state['max_deferred_updates'],
|
||||
'deferred_update_ttl': self._scrolling_state['deferred_update_ttl']
|
||||
}
|
||||
|
||||
def _viewer_is_fresh(self, now: float) -> bool:
|
||||
"""True when a browser preview is watching (marker file touched by
|
||||
the web SSE broadcaster). The marker is stat'd at most once per
|
||||
|
||||
+4
-240
@@ -40,13 +40,7 @@ from pathlib import Path
|
||||
from PIL import ImageFont
|
||||
from src.common.bdf_font import load_bdf_face, read_bdf_native_size
|
||||
from src.common.font_layout import load_truetype, resolve_asset_path
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
get_assets_dir_mode,
|
||||
get_config_dir_mode,
|
||||
)
|
||||
from typing import Dict, Tuple, Optional, Union, Any, List
|
||||
from src.deprecation import deprecated
|
||||
from typing import Dict, Tuple, Optional, Union, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -93,7 +87,7 @@ class FontManager:
|
||||
self.temp_font_dir = Path(tempfile.gettempdir()) / "ledmatrix_fonts"
|
||||
self.temp_font_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Counters behind get_performance_stats().
|
||||
# Font-load counters, kept up by get_font().
|
||||
self.performance_stats = {
|
||||
"cache_hits": 0,
|
||||
"cache_misses": 0,
|
||||
@@ -109,12 +103,8 @@ class FontManager:
|
||||
"tom_thumb": "assets/fonts/tom-thumb.bdf"
|
||||
}
|
||||
|
||||
# Size tokens for convenience
|
||||
self.size_tokens = {
|
||||
"xs": 6, "sm": 8, "md": 10, "lg": 12, "xl": 14, "xxl": 16
|
||||
}
|
||||
|
||||
# Font overrides storage (for manual overrides)
|
||||
# Per-element overrides read from config/font_overrides.json;
|
||||
# resolve_font applies them.
|
||||
# Under the install root's config/ (which always exists), not the
|
||||
# cwd: the file itself may not exist yet, and resolve_asset_path
|
||||
# hands back a missing path unchanged.
|
||||
@@ -187,26 +177,6 @@ class FontManager:
|
||||
if removed:
|
||||
self.manager_fonts_version += 1
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def get_manager_fonts(self, manager_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Get registered fonts for a specific manager or all managers.
|
||||
|
||||
Args:
|
||||
manager_id: Optional manager ID, if None returns all
|
||||
|
||||
Returns:
|
||||
Dictionary of registered fonts
|
||||
"""
|
||||
if manager_id:
|
||||
return self.manager_fonts.get(manager_id, {})
|
||||
return self.manager_fonts.copy()
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def get_detected_fonts(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Get all detected font usage across managers."""
|
||||
return self.detected_fonts.copy()
|
||||
|
||||
# ==================== Plugin Font Management ====================
|
||||
|
||||
def register_plugin_fonts(self, plugin_id: str, font_manifest: Dict[str, Any],
|
||||
@@ -433,51 +403,6 @@ class FontManager:
|
||||
search_dirs = [Path(resolve_asset_path(configured)), Path(resolve_asset_path("plugins"))]
|
||||
return resolve_plugin_dir(plugin_id, search_dirs, prefix=True)
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def unregister_plugin_fonts(self, plugin_id: str) -> bool:
|
||||
"""Unregister all fonts for a plugin."""
|
||||
try:
|
||||
if plugin_id in self.plugin_fonts:
|
||||
# Remove from plugin catalogs
|
||||
if plugin_id in self.plugin_font_catalogs:
|
||||
for family in self.plugin_font_catalogs[plugin_id]:
|
||||
namespaced_family = f"{plugin_id}::{family}"
|
||||
if namespaced_family in self.font_catalog:
|
||||
del self.font_catalog[namespaced_family]
|
||||
|
||||
del self.plugin_font_catalogs[plugin_id]
|
||||
|
||||
# Remove plugin manifest
|
||||
del self.plugin_fonts[plugin_id]
|
||||
|
||||
# Clear related cache entries
|
||||
self._clear_plugin_font_cache(plugin_id)
|
||||
|
||||
logger.info(f"Unregistered fonts for plugin {plugin_id}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error unregistering plugin fonts: {e}")
|
||||
return False
|
||||
|
||||
def _clear_plugin_font_cache(self, plugin_id: str):
|
||||
"""Clear font cache entries for a specific plugin."""
|
||||
keys_to_remove = [key for key in self.font_cache.keys() if key.startswith(f"{plugin_id}::")]
|
||||
for key in keys_to_remove:
|
||||
del self.font_cache[key]
|
||||
if keys_to_remove:
|
||||
# Font objects someone may hold were dropped; see cache_generation.
|
||||
self.cache_generation += 1
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def get_plugin_fonts(self, plugin_id: str) -> List[str]:
|
||||
"""Get list of font families registered by a plugin."""
|
||||
if plugin_id in self.plugin_font_catalogs:
|
||||
return list(self.plugin_font_catalogs[plugin_id].keys())
|
||||
return []
|
||||
|
||||
# ==================== Font Resolution ====================
|
||||
|
||||
def resolve_font(self, element_key: str, family: str, size_px: int,
|
||||
@@ -668,42 +593,6 @@ class FontManager:
|
||||
logger.error(f"Error getting font height: {e}", exc_info=True)
|
||||
return 12 # Default height
|
||||
|
||||
# ==================== Override Management ====================
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def set_override(self, element_key: str, family: str = None, size_px: int = None):
|
||||
"""Set font override for a specific element."""
|
||||
if element_key not in self.font_overrides:
|
||||
self.font_overrides[element_key] = {}
|
||||
|
||||
if family is not None:
|
||||
self.font_overrides[element_key]["family"] = family
|
||||
if size_px is not None:
|
||||
self.font_overrides[element_key]["size_px"] = size_px
|
||||
|
||||
# Remove empty overrides
|
||||
if not self.font_overrides[element_key]:
|
||||
del self.font_overrides[element_key]
|
||||
else:
|
||||
self._save_overrides()
|
||||
|
||||
self.clear_cache()
|
||||
logger.info(f"Font override set for {element_key}: {self.font_overrides.get(element_key, {})}")
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def remove_override(self, element_key: str):
|
||||
"""Remove font override for a specific element."""
|
||||
if element_key in self.font_overrides:
|
||||
del self.font_overrides[element_key]
|
||||
self._save_overrides()
|
||||
self.clear_cache()
|
||||
logger.info(f"Font override removed for {element_key}")
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def get_overrides(self) -> Dict[str, Dict[str, str]]:
|
||||
"""Get current font overrides."""
|
||||
return self.font_overrides.copy()
|
||||
|
||||
# ==================== Font Discovery ====================
|
||||
|
||||
@staticmethod
|
||||
@@ -765,17 +654,6 @@ class FontManager:
|
||||
logger.warning(f"Could not load font overrides: {e}")
|
||||
self.font_overrides = {}
|
||||
|
||||
def _save_overrides(self):
|
||||
"""Save current font overrides to file."""
|
||||
try:
|
||||
font_overrides_path = Path(self.font_overrides_file)
|
||||
ensure_directory_permissions(font_overrides_path.parent, get_config_dir_mode())
|
||||
with open(self.font_overrides_file, 'w') as f:
|
||||
json.dump(self.font_overrides, f, indent=2)
|
||||
logger.info(f"Saved {len(self.font_overrides)} font overrides")
|
||||
except Exception as e:
|
||||
logger.error(f"Could not save font overrides: {e}")
|
||||
|
||||
# ==================== Utility Methods ====================
|
||||
|
||||
def clear_cache(self):
|
||||
@@ -786,117 +664,3 @@ class FontManager:
|
||||
# without the bump they kept serving results for the dropped fonts.
|
||||
self.cache_generation += 1
|
||||
logger.info("Font cache cleared")
|
||||
|
||||
@deprecated("3.8.0", "read font_catalog")
|
||||
def get_available_fonts(self) -> Dict[str, str]:
|
||||
"""Get dictionary of available font families and their paths."""
|
||||
return self.font_catalog.copy()
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def get_size_tokens(self) -> Dict[str, int]:
|
||||
"""Get available size tokens."""
|
||||
return self.size_tokens.copy()
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def get_performance_stats(self) -> Dict[str, Any]:
|
||||
"""Get performance statistics."""
|
||||
uptime = time.time() - self.performance_stats["start_time"]
|
||||
return {
|
||||
"uptime_seconds": uptime,
|
||||
"cache_hits": self.performance_stats["cache_hits"],
|
||||
"cache_misses": self.performance_stats["cache_misses"],
|
||||
"cache_hit_rate": (
|
||||
self.performance_stats["cache_hits"] /
|
||||
(self.performance_stats["cache_hits"] + self.performance_stats["cache_misses"])
|
||||
if (self.performance_stats["cache_hits"] + self.performance_stats["cache_misses"]) > 0 else 0
|
||||
),
|
||||
"total_fonts_cached": len(self.font_cache),
|
||||
"total_metrics_cached": len(self.metrics_cache),
|
||||
"failed_loads": self.performance_stats["failed_loads"],
|
||||
"total_fonts_available": len(self.font_catalog),
|
||||
"plugin_fonts": len(self.plugin_fonts),
|
||||
"manager_fonts": len(self.manager_fonts),
|
||||
"detected_fonts": len(self.detected_fonts)
|
||||
}
|
||||
|
||||
@deprecated("3.8.0", "read font_catalog")
|
||||
def get_font_catalog(self) -> Dict[str, str]:
|
||||
"""Get the current font catalog."""
|
||||
return self.font_catalog.copy()
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def add_font(self, font_file_path: str, family_name: str) -> bool:
|
||||
"""Add ``font_file_path`` to the catalog as ``family_name``. The file
|
||||
stays where it is; only assets/fonts is created if it is missing."""
|
||||
try:
|
||||
# Validate font file
|
||||
if not os.path.exists(font_file_path):
|
||||
logger.error(f"Font file not found: {font_file_path}")
|
||||
return False
|
||||
|
||||
# Check if family name already exists
|
||||
if family_name in self.font_catalog:
|
||||
logger.warning(f"Font family '{family_name}' already exists")
|
||||
return False
|
||||
|
||||
fonts_dir = Path(resolve_asset_path("assets/fonts"))
|
||||
ensure_directory_permissions(fonts_dir, get_assets_dir_mode())
|
||||
|
||||
# Add to catalog
|
||||
self.font_catalog[family_name] = font_file_path
|
||||
self.clear_cache()
|
||||
logger.info(f"Added font {family_name}: {font_file_path}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding font {family_name}: {e}")
|
||||
return False
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def remove_font(self, family_name: str) -> bool:
|
||||
"""Remove a font from the catalog."""
|
||||
try:
|
||||
if family_name not in self.font_catalog:
|
||||
logger.warning(f"Font family '{family_name}' not found")
|
||||
return False
|
||||
|
||||
# Check if font is currently in use
|
||||
in_use = False
|
||||
for override in self.font_overrides.values():
|
||||
if override.get("family") == family_name:
|
||||
in_use = True
|
||||
break
|
||||
|
||||
if in_use:
|
||||
logger.error(f"Cannot remove font '{family_name}' - it is currently in use")
|
||||
return False
|
||||
|
||||
del self.font_catalog[family_name]
|
||||
self.clear_cache()
|
||||
logger.info(f"Removed font {family_name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing font {family_name}: {e}")
|
||||
return False
|
||||
|
||||
@deprecated("3.8.0")
|
||||
def validate_font(self, font_path: str) -> Dict[str, Any]:
|
||||
"""Validate a font file."""
|
||||
try:
|
||||
if not os.path.exists(font_path):
|
||||
return {"valid": False, "error": "Font file not found"}
|
||||
|
||||
if font_path.endswith('.bdf'):
|
||||
# Try to load BDF font
|
||||
freetype.Face(font_path)
|
||||
return {"valid": True, "type": "bdf", "family": "unknown"}
|
||||
elif font_path.endswith('.ttf'):
|
||||
# Try to load TTF font
|
||||
load_truetype(font_path, 12)
|
||||
return {"valid": True, "type": "ttf", "family": "unknown"}
|
||||
else:
|
||||
return {"valid": False, "error": "Unsupported font format"}
|
||||
|
||||
except Exception as e:
|
||||
return {"valid": False, "error": str(e)}
|
||||
|
||||
@@ -32,7 +32,6 @@ from src.plugin_system.schema_manager import (
|
||||
from src.plugin_system.plugin_dirs import (
|
||||
ManifestStatus, PluginDirectoryIndex, resolve_plugin_dir,
|
||||
)
|
||||
from src.deprecation import deprecated
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
get_plugin_dir_mode
|
||||
@@ -873,16 +872,6 @@ class PluginManager:
|
||||
"""
|
||||
return self.plugins.copy()
|
||||
|
||||
@deprecated("3.8.0", "check each plugin's enabled flag in plugins")
|
||||
def get_enabled_plugins(self) -> List[str]:
|
||||
"""
|
||||
Get list of enabled plugin IDs.
|
||||
|
||||
Returns:
|
||||
List of plugin IDs that are currently enabled
|
||||
"""
|
||||
return [pid for pid, plugin in self.plugins.items() if plugin.enabled]
|
||||
|
||||
def get_plugin_info(self, plugin_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get information about a plugin (manifest + runtime info).
|
||||
|
||||
@@ -14,9 +14,7 @@ PIL Image canvas and draws text using the actual project fonts.
|
||||
MAINTENANCE WARNING: this class is a deliberate fork of
|
||||
src/display_manager.py so it can run without hardware. It mirrors
|
||||
these DisplayManager methods by name and behavior: _load_fonts,
|
||||
get_font_height, get_text_width, draw_text,
|
||||
draw_text_with_icons, draw_weather_icon (and the _draw_sun/_draw_cloud/
|
||||
_draw_rain/_draw_snow/_draw_storm family), format_date_with_ordinal,
|
||||
get_font_height, get_text_width, draw_text, format_date_with_ordinal,
|
||||
capture_mode, set_scrolling_state, is_currently_scrolling,
|
||||
process_deferred_updates, update_display, render_size, offscreen. A behavior
|
||||
change to any of those in DisplayManager must be mirrored here, or
|
||||
@@ -26,13 +24,12 @@ BDF text is not mirrored: both classes load BDF faces and draw BDF glyphs
|
||||
through src/common/bdf_font.py, so those pixels cannot drift.
|
||||
"""
|
||||
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
import warnings
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional, Tuple
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from src.common.bdf_font import draw_bdf_text, load_bdf_face
|
||||
@@ -63,15 +60,6 @@ class VisualTestDisplayManager:
|
||||
no emulator dependency.
|
||||
"""
|
||||
|
||||
# Weather icon color constants (same as DisplayManager)
|
||||
WEATHER_COLORS = {
|
||||
'sun': (255, 200, 0),
|
||||
'cloud': (200, 200, 200),
|
||||
'rain': (0, 100, 255),
|
||||
'snow': (220, 220, 255),
|
||||
'storm': (255, 255, 0),
|
||||
}
|
||||
|
||||
def __init__(self, width: int = 128, height: int = 32):
|
||||
self._width = width
|
||||
self._height = height
|
||||
@@ -410,129 +398,6 @@ class VisualTestDisplayManager:
|
||||
return font.size
|
||||
return 8
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Weather drawing helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def draw_sun(self, x: int, y: int, size: int = 16):
|
||||
"""Draw a sun icon using yellow circles and lines."""
|
||||
self._draw_sun(x, y, size)
|
||||
|
||||
def draw_cloud(self, x: int, y: int, size: int = 16, color: Tuple[int, int, int] = (200, 200, 200)):
|
||||
"""Draw a cloud icon."""
|
||||
self._draw_cloud(x, y, size, color)
|
||||
|
||||
def draw_rain(self, x: int, y: int, size: int = 16):
|
||||
"""Draw rain icon with cloud and droplets."""
|
||||
self._draw_rain(x, y, size)
|
||||
|
||||
def draw_snow(self, x: int, y: int, size: int = 16):
|
||||
"""Draw snow icon with cloud and snowflakes."""
|
||||
self._draw_snow(x, y, size)
|
||||
|
||||
def _draw_sun(self, x: int, y: int, size: int) -> None:
|
||||
"""Draw a sun icon with rays (internal weather icon version)."""
|
||||
center_x, center_y = x + size // 2, y + size // 2
|
||||
radius = size // 4
|
||||
ray_length = size // 3
|
||||
self.draw.ellipse(
|
||||
[center_x - radius, center_y - radius,
|
||||
center_x + radius, center_y + radius],
|
||||
fill=self.WEATHER_COLORS['sun'],
|
||||
)
|
||||
for angle in range(0, 360, 45):
|
||||
rad = math.radians(angle)
|
||||
start_x = center_x + int((radius + 2) * math.cos(rad))
|
||||
start_y = center_y + int((radius + 2) * math.sin(rad))
|
||||
end_x = center_x + int((radius + ray_length) * math.cos(rad))
|
||||
end_y = center_y + int((radius + ray_length) * math.sin(rad))
|
||||
self.draw.line([start_x, start_y, end_x, end_y], fill=self.WEATHER_COLORS['sun'], width=2)
|
||||
|
||||
def _draw_cloud(self, x: int, y: int, size: int, color: Optional[Tuple[int, int, int]] = None) -> None:
|
||||
"""Draw a cloud using multiple circles (internal weather icon version)."""
|
||||
cloud_color = color if color is not None else self.WEATHER_COLORS['cloud']
|
||||
base_y = y + size // 2
|
||||
circle_radius = size // 4
|
||||
positions = [
|
||||
(x + size // 3, base_y),
|
||||
(x + size // 2, base_y - size // 6),
|
||||
(x + 2 * size // 3, base_y),
|
||||
]
|
||||
for cx, cy in positions:
|
||||
self.draw.ellipse(
|
||||
[cx - circle_radius, cy - circle_radius,
|
||||
cx + circle_radius, cy + circle_radius],
|
||||
fill=cloud_color,
|
||||
)
|
||||
|
||||
def _draw_rain(self, x: int, y: int, size: int) -> None:
|
||||
"""Draw rain drops falling from a cloud."""
|
||||
self._draw_cloud(x, y, size)
|
||||
rain_color = self.WEATHER_COLORS['rain']
|
||||
drop_size = size // 8
|
||||
drops = [
|
||||
(x + size // 4, y + 2 * size // 3),
|
||||
(x + size // 2, y + 3 * size // 4),
|
||||
(x + 3 * size // 4, y + 2 * size // 3),
|
||||
]
|
||||
for dx, dy in drops:
|
||||
self.draw.line([dx, dy, dx - drop_size // 2, dy + drop_size], fill=rain_color, width=2)
|
||||
|
||||
def _draw_snow(self, x: int, y: int, size: int) -> None:
|
||||
"""Draw snowflakes falling from a cloud."""
|
||||
self._draw_cloud(x, y, size)
|
||||
snow_color = self.WEATHER_COLORS['snow']
|
||||
flake_size = size // 6
|
||||
flakes = [
|
||||
(x + size // 4, y + 2 * size // 3),
|
||||
(x + size // 2, y + 3 * size // 4),
|
||||
(x + 3 * size // 4, y + 2 * size // 3),
|
||||
]
|
||||
for fx, fy in flakes:
|
||||
for angle in range(0, 360, 60):
|
||||
rad = math.radians(angle)
|
||||
end_x = fx + int(flake_size * math.cos(rad))
|
||||
end_y = fy + int(flake_size * math.sin(rad))
|
||||
self.draw.line([fx, fy, end_x, end_y], fill=snow_color, width=1)
|
||||
|
||||
def _draw_storm(self, x: int, y: int, size: int) -> None:
|
||||
"""Draw a storm cloud with lightning bolt."""
|
||||
self._draw_cloud(x, y, size)
|
||||
bolt_color = self.WEATHER_COLORS['storm']
|
||||
bolt_points = [
|
||||
(x + size // 2, y + size // 2),
|
||||
(x + 3 * size // 5, y + 2 * size // 3),
|
||||
(x + 2 * size // 5, y + 2 * size // 3),
|
||||
(x + size // 2, y + 5 * size // 6),
|
||||
]
|
||||
self.draw.polygon(bolt_points, fill=bolt_color)
|
||||
|
||||
def draw_weather_icon(self, condition: str, x: int, y: int, size: int = 16) -> None:
|
||||
"""Draw a weather icon based on the condition."""
|
||||
cond = condition.lower()
|
||||
if cond in ('clear', 'sunny'):
|
||||
self._draw_sun(x, y, size)
|
||||
elif cond in ('clouds', 'cloudy', 'partly cloudy'):
|
||||
self._draw_cloud(x, y, size)
|
||||
elif cond in ('rain', 'drizzle', 'shower'):
|
||||
self._draw_rain(x, y, size)
|
||||
elif cond in ('snow', 'sleet', 'hail'):
|
||||
self._draw_snow(x, y, size)
|
||||
elif cond in ('thunderstorm', 'storm'):
|
||||
self._draw_storm(x, y, size)
|
||||
else:
|
||||
self._draw_sun(x, y, size)
|
||||
|
||||
def draw_text_with_icons(self, text: str, icons: List[tuple] = None,
|
||||
x: int = None, y: int = None,
|
||||
color: tuple = (255, 255, 255)):
|
||||
"""Draw text with weather icons at specified positions."""
|
||||
self.draw_text(text, x, y, color)
|
||||
if icons:
|
||||
for icon_type, icon_x, icon_y in icons:
|
||||
self.draw_weather_icon(icon_type, icon_x, icon_y)
|
||||
self.update_display()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Scrolling state (no-op interface compat)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -196,42 +196,3 @@ class TestVisualDisplayManager:
|
||||
assert '11th' in result
|
||||
|
||||
|
||||
class TestWeatherDrawing:
|
||||
"""Test weather icon rendering."""
|
||||
|
||||
def test_draw_sun(self):
|
||||
vdm = VisualTestDisplayManager(width=128, height=32)
|
||||
vdm.draw_sun(0, 0, 16)
|
||||
pixels = list(vdm.image.getdata())
|
||||
non_black = [p for p in pixels if p != (0, 0, 0)]
|
||||
assert len(non_black) > 0
|
||||
|
||||
def test_draw_cloud(self):
|
||||
vdm = VisualTestDisplayManager(width=128, height=32)
|
||||
vdm.draw_cloud(0, 0, 16)
|
||||
pixels = list(vdm.image.getdata())
|
||||
non_black = [p for p in pixels if p != (0, 0, 0)]
|
||||
assert len(non_black) > 0
|
||||
|
||||
def test_draw_rain(self):
|
||||
vdm = VisualTestDisplayManager(width=128, height=32)
|
||||
vdm.draw_rain(0, 0, 16)
|
||||
pixels = list(vdm.image.getdata())
|
||||
non_black = [p for p in pixels if p != (0, 0, 0)]
|
||||
assert len(non_black) > 0
|
||||
|
||||
def test_draw_snow(self):
|
||||
vdm = VisualTestDisplayManager(width=128, height=32)
|
||||
vdm.draw_snow(0, 0, 16)
|
||||
pixels = list(vdm.image.getdata())
|
||||
non_black = [p for p in pixels if p != (0, 0, 0)]
|
||||
assert len(non_black) > 0
|
||||
|
||||
def test_draw_weather_icon_dispatches(self):
|
||||
vdm = VisualTestDisplayManager(width=128, height=32)
|
||||
for condition in ['clear', 'cloudy', 'rain', 'snow', 'storm', 'unknown']:
|
||||
vdm.clear()
|
||||
vdm.draw_weather_icon(condition, 0, 0, 16)
|
||||
pixels = list(vdm.image.getdata())
|
||||
non_black = [p for p in pixels if p != (0, 0, 0)]
|
||||
assert len(non_black) > 0, f"draw_weather_icon('{condition}') should render pixels"
|
||||
|
||||
@@ -40,11 +40,9 @@ def test_cleanup_and_stats_follow_a_replaced_component(cm):
|
||||
assert cm._memory_cache_component.get("stale") is None
|
||||
assert cm._memory_cache_component.get("fresh") == {"v": 1}
|
||||
|
||||
stats = cm.get_memory_cache_stats()
|
||||
assert stats["size"] == 1
|
||||
assert stats["max_size"] == 7
|
||||
assert stats["cleanup_interval"] == 11.0
|
||||
assert stats["usage_percent"] == pytest.approx(100 / 7)
|
||||
with patch.object(cm.logger, "info") as info:
|
||||
cm.log_memory_cache_stats()
|
||||
assert "Size: 1/7 (14.3%)" in info.call_args[0][0]
|
||||
|
||||
|
||||
def test_periodic_cleanup_is_throttled_and_records_its_run(cm):
|
||||
@@ -60,16 +58,16 @@ def test_periodic_cleanup_is_throttled_and_records_its_run(cm):
|
||||
before = time.time()
|
||||
cm.get_cached_data("missing") # triggers the periodic sweep
|
||||
assert mem.size() == 0
|
||||
assert cm.get_memory_cache_stats()["last_cleanup"] >= before
|
||||
assert mem.get_stats()["last_cleanup"] >= before
|
||||
|
||||
|
||||
def test_stats_have_the_documented_shape(cm):
|
||||
def test_memory_stats_log_reads_the_live_tier(cm):
|
||||
cm.set("k", {"v": 1})
|
||||
stats = cm.get_memory_cache_stats()
|
||||
assert set(stats) == {"size", "max_size", "usage_percent",
|
||||
"last_cleanup", "cleanup_interval"}
|
||||
assert stats["size"] == 1
|
||||
assert stats["max_size"] == cm._memory_cache_component.max_size()
|
||||
with patch.object(cm.logger, "info") as info:
|
||||
cm.log_memory_cache_stats()
|
||||
message = info.call_args[0][0]
|
||||
assert f"Size: 1/{cm._memory_cache_component.max_size()}" in message
|
||||
assert "Last cleanup:" in message
|
||||
|
||||
|
||||
def test_listing_the_cache_dir_does_not_hold_the_memory_lock(cm, tmp_path):
|
||||
|
||||
+62
-40
@@ -20,31 +20,10 @@ from src.deprecation import deprecated
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
|
||||
#: Everything deprecated for removal in 3.8.0 (first announced for 3.7.0,
|
||||
#: which shipped with all of them still in place). docs/DEPRECATIONS_3.8.md
|
||||
#: says which are unused. Removing one of these, or deprecating another,
|
||||
#: should be a deliberate edit here too.
|
||||
DEPRECATED = {
|
||||
"src.cache_manager.CacheManager": [
|
||||
"has_data_changed", "update_cache", "setup_persistent_cache",
|
||||
"get_sport_live_interval", "get_sport_key_from_cache_key",
|
||||
"get_background_cached_data", "is_background_data_available",
|
||||
"record_cache_hit", "record_cache_miss", "record_fetch_time",
|
||||
"get_cache_metrics", "log_cache_metrics", "get_memory_cache_stats",
|
||||
],
|
||||
"src.display_manager.DisplayManager": [
|
||||
"draw_sun", "draw_cloud", "draw_rain", "draw_snow", "draw_weather_icon",
|
||||
"draw_text_with_icons", "get_scrolling_stats",
|
||||
],
|
||||
"src.font_manager.FontManager": [
|
||||
"get_manager_fonts", "get_detected_fonts", "unregister_plugin_fonts",
|
||||
"get_plugin_fonts", "set_override", "remove_override", "get_overrides",
|
||||
"get_available_fonts", "get_size_tokens", "get_performance_stats",
|
||||
"get_font_catalog", "add_font", "remove_font", "validate_font",
|
||||
],
|
||||
"src.plugin_system.plugin_manager.PluginManager": ["get_enabled_plugins"],
|
||||
}
|
||||
|
||||
#: Everything still deprecated. (The 35 methods deprecated for 3.8.0 were
|
||||
#: removed in it: docs/DEPRECATIONS_3.8.md found them unused.) Removing one
|
||||
#: of these, or deprecating another, should be a deliberate edit here too.
|
||||
#:
|
||||
#: Deprecated with Vegas participation, for removal in 3.9.0: core never read
|
||||
#: them (src.plugin_system.base_plugin.VEGAS_LEGACY_REMOVAL).
|
||||
DEPRECATED_3_9 = {
|
||||
@@ -55,7 +34,7 @@ DEPRECATED_3_9 = {
|
||||
|
||||
#: Every pinned marker: (class path, method) -> the release that removes it.
|
||||
PINNED = {(path, name): removal
|
||||
for removal, table in (("3.8.0", DEPRECATED), ("3.9.0", DEPRECATED_3_9))
|
||||
for removal, table in (("3.9.0", DEPRECATED_3_9),)
|
||||
for path, names in table.items() for name in names}
|
||||
|
||||
|
||||
@@ -132,7 +111,57 @@ def test_usage_script_lists_exactly_the_pinned_markers(usage_script):
|
||||
assert found == {(path, name, removal) for (path, name), removal in PINNED.items()}
|
||||
|
||||
|
||||
def test_usage_script_tells_uses_from_name_collisions(usage_script, tmp_path):
|
||||
#: A stand-in core for the scanner tests below, so they keep working whichever
|
||||
#: real markers exist (the 3.8.0 ones they were written against are gone).
|
||||
FAKE_CORE = {
|
||||
"src/cache_manager.py": """\
|
||||
class CacheManager:
|
||||
@deprecated("9.9.0", "use set()")
|
||||
def update_cache(self, key, data):
|
||||
pass
|
||||
""",
|
||||
"src/display_manager.py": """\
|
||||
class DisplayManager:
|
||||
@deprecated("9.9.0")
|
||||
def draw_sun(self, x, y):
|
||||
pass
|
||||
|
||||
@deprecated("9.9.0")
|
||||
def draw_cloud(self, x, y):
|
||||
pass
|
||||
|
||||
@deprecated("9.9.0")
|
||||
def draw_rain(self, x, y):
|
||||
self.draw_cloud(x, y)
|
||||
|
||||
@deprecated("9.9.0")
|
||||
def draw_snow(self, x, y):
|
||||
pass
|
||||
|
||||
@deprecated("9.9.0")
|
||||
def get_scrolling_stats(self):
|
||||
return {}
|
||||
""",
|
||||
"src/font_manager.py": """\
|
||||
class FontManager:
|
||||
@deprecated("9.9.0")
|
||||
def add_font(self, path, name):
|
||||
return True
|
||||
""",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_core(tmp_path):
|
||||
root = tmp_path / "core"
|
||||
for rel, source in FAKE_CORE.items():
|
||||
path = root / rel
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(textwrap.dedent(source), encoding="utf-8")
|
||||
return root
|
||||
|
||||
|
||||
def test_usage_script_tells_uses_from_name_collisions(usage_script, fake_core, tmp_path):
|
||||
"""Calls on the owning object and overrides count; same-named methods of
|
||||
unrelated classes and hits in test files do not."""
|
||||
plugin = tmp_path / "demo"
|
||||
@@ -163,7 +192,7 @@ def test_usage_script_tells_uses_from_name_collisions(usage_script, tmp_path):
|
||||
display_manager.draw_snow.assert_not_called()
|
||||
"""), encoding="utf-8")
|
||||
|
||||
markers = usage_script.find_markers(REPO)
|
||||
markers = usage_script.find_markers(fake_core)
|
||||
source = usage_script.Source("demo", "monorepo", plugin)
|
||||
usage_script.scan_tree(source, [plugin], plugin, markers, core=False)
|
||||
kinds = {key: sorted(("test " if h.test else "") + h.kind for h in hits)
|
||||
@@ -186,11 +215,12 @@ def test_usage_script_tells_uses_from_name_collisions(usage_script, tmp_path):
|
||||
assert status["DisplayManager.draw_snow"][0] == "unused" # a test mock only
|
||||
|
||||
|
||||
def test_usage_script_follows_calls_between_deprecated_core_methods(usage_script):
|
||||
def test_usage_script_follows_calls_between_deprecated_core_methods(usage_script, fake_core):
|
||||
"""draw_rain calls draw_cloud; with no outside callers both are unused."""
|
||||
markers = usage_script.find_markers(REPO)
|
||||
core = usage_script.Source("core", "core", REPO)
|
||||
usage_script.scan_tree(core, [REPO / "src" / "display_manager.py"], REPO, markers, core=True)
|
||||
markers = usage_script.find_markers(fake_core)
|
||||
core = usage_script.Source("core", "core", fake_core)
|
||||
usage_script.scan_tree(core, [fake_core / "src" / "display_manager.py"], fake_core,
|
||||
markers, core=True)
|
||||
kinds = {h.kind for h in core.hits["DisplayManager.draw_cloud"]}
|
||||
assert kinds == {"internal"}
|
||||
assert usage_script.verdicts(markers, [core])["DisplayManager.draw_cloud"][0] == "unused"
|
||||
@@ -219,11 +249,3 @@ def test_first_call_warns_and_logs_then_stays_quiet(fresh, caplog):
|
||||
assert caught[0].filename == __file__ # points at the caller
|
||||
assert sum("will be removed in LEDMatrix 9.9.9" in r.message for r in caplog.records) == 1
|
||||
assert old.__name__ == "old" and old.__doc__ == "Doc."
|
||||
|
||||
|
||||
def test_decorated_methods_still_work(fresh):
|
||||
from src.font_manager import FontManager
|
||||
fm = FontManager({})
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
assert fm.get_font_catalog() == fm.font_catalog
|
||||
|
||||
@@ -150,16 +150,6 @@ class TestCacheLifecycle:
|
||||
fm.clear_cache()
|
||||
assert fm.cache_generation == gen_before + 1
|
||||
|
||||
def test_clearing_a_plugins_cached_fonts_bumps_generation(self, fm):
|
||||
fm.font_cache["demo::tiny_8"] = object()
|
||||
gen_before = fm.cache_generation
|
||||
fm._clear_plugin_font_cache("demo")
|
||||
assert "demo::tiny_8" not in fm.font_cache
|
||||
assert fm.cache_generation == gen_before + 1
|
||||
# Nothing to drop, nothing to rebuild.
|
||||
fm._clear_plugin_font_cache("demo")
|
||||
assert fm.cache_generation == gen_before + 1
|
||||
|
||||
|
||||
class TestPluginFonts:
|
||||
"""plugin:// sources resolve against the plugin's own directory, which
|
||||
|
||||
Reference in New Issue
Block a user