diff --git a/plugins.json b/plugins.json index 926231b4..5b032881 100644 --- a/plugins.json +++ b/plugins.json @@ -928,10 +928,10 @@ "repo": "https://github.com/ChuckBuilds/ledmatrix-plugins", "branch": "main", "plugin_path": "plugins/ledmatrix-weather", - "latest_version": "2.6.2", + "latest_version": "2.6.3", "stars": 0, "downloads": 0, - "last_updated": "2026-07-19", + "last_updated": "2026-08-05", "verified": true, "screenshot": "" }, diff --git a/plugins/ledmatrix-weather/manager.py b/plugins/ledmatrix-weather/manager.py index 2a4c2111..3d0c032c 100644 --- a/plugins/ledmatrix-weather/manager.py +++ b/plugins/ledmatrix-weather/manager.py @@ -153,6 +153,11 @@ def __init__(self, plugin_id: str, config: Dict[str, Any], # reference fitting all 7 possible items (192 // 7 ~= 27px) without # crowding. self.MIN_METRIC_ITEM_WIDTH_PX = 27 + # Gap the compact Vegas tile keeps between metrics-bar items. This is a + # readability floor, not a packing minimum: MIN_METRIC_ITEM_WIDTH_PX + # above is the point where items start to collide, which is a long way + # below the point where a scrolling row is comfortable to read. + self.COMPACT_METRIC_GAP_PX = 24 self.COLORS = { 'text': (255, 255, 255), 'highlight': (255, 200, 0), @@ -1039,10 +1044,16 @@ def _display_no_data(self) -> None: self.display_manager.image = img self.display_manager.update_display() - def _render_current_weather_image(self) -> Optional[Image.Image]: - """Render current weather conditions to an Image without display side effects.""" + def _render_current_weather_image(self, width: Optional[int] = None) -> Optional[Image.Image]: + """Render current weather conditions to an Image without display side effects. + + width defaults to the panel. The Vegas ticker passes a narrower one so + the tile is this same layout simply drawn closer together: the + condition/temperature/high-low stack right-aligns to whatever width it + is given, and the metrics bar divides that width rather than the panel. + """ try: - width = self.display_manager.matrix.width + width = width or self.display_manager.matrix.width height = self.display_manager.matrix.height img = Image.new('RGB', (width, height), (0, 0, 0)) draw = ImageDraw.Draw(img) @@ -1103,35 +1114,9 @@ def _render_current_weather_image(self) -> Optional[Image.Image]: wind_dir = self._get_wind_direction(wind_deg) wind_gust = self.weather_data['wind'].get('gust') - # Core items (always shown) plus a droppable tag for width-aware - # thinning below — None means "never drop" (UV/H/W and, once - # enabled, feels-like are always kept; only dew point/visibility/ - # pressure are shed as the panel gets too narrow to fit them all - # legibly, least-useful first). - uv_color = self._get_uv_color(uv_index) - all_items = [] # list of (text, color, drop_tag) - all_items.append((f"UV:{uv_index:.0f}", uv_color, None)) - all_items.append((f"H:{humidity}%", self.COLORS['dim'], None)) - if wind_gust and wind_gust > wind_speed * 1.3: - all_items.append((f"W:{wind_speed:.0f}g{wind_gust:.0f}{wind_dir}", self.COLORS['dim'], None)) - else: - all_items.append((f"W:{wind_speed:.0f}{wind_dir}", self.COLORS['dim'], None)) - - # Extra items — merged into same row (no degree symbol, font can't render it) - if self.show_feels_like and feels_like is not None: - all_items.append((f"FL:{int(feels_like)}", self.COLORS['dim'], None)) - if self.show_dew_point and dew_point is not None: - all_items.append((f"Dew:{int(dew_point)}", self.COLORS['dim'], 'dew_point')) - if self.show_visibility and visibility_m is not None: - vis_val = visibility_m / 1609.34 if self.units == 'imperial' else visibility_m / 1000 - vis_u = "mi" if self.units == 'imperial' else "km" - all_items.append((f"Vis:{vis_val:.0f}{vis_u}", self.COLORS['dim'], 'visibility')) - if self.show_pressure and pressure is not None: - if self.units == 'imperial': - pv = pressure * 0.02953 - all_items.append((f"P:{pv:.2f}\"", self.COLORS['dim'], 'pressure')) - else: - all_items.append((f"P:{int(pressure)}hPa", self.COLORS['dim'], 'pressure')) + all_items = self._build_metric_items( + uv_index, humidity, wind_speed, wind_dir, wind_gust, + feels_like, dew_point, visibility_m, pressure) # Drop least-useful optional items, in this order, while an equal # split still leaves each item too cramped to read (below @@ -1157,6 +1142,46 @@ def _render_current_weather_image(self) -> Optional[Image.Image]: self.logger.exception("Error rendering current weather") return None + def _build_metric_items(self, uv_index, humidity, wind_speed, wind_dir, + wind_gust, feels_like, dew_point, visibility_m, + pressure) -> List[tuple]: + """The bottom-bar metrics as (text, color, drop_tag) tuples. + + Shared by the full-screen layout and the Vegas tile so the two cannot + drift: the colors carry meaning (UV is graded by severity) and the + show_* toggles decide what appears at all, neither of which should + depend on which renderer you happen to be looking at. + + drop_tag None means "never drop" — UV/H/W and, once enabled, + feels-like are always kept; only dew point/visibility/pressure are + shed when the panel is too narrow to fit them all legibly. + """ + items = [ + (f"UV:{uv_index:.0f}", self._get_uv_color(uv_index), None), + (f"H:{humidity}%", self.COLORS['dim'], None), + ] + if wind_gust and wind_gust > wind_speed * 1.3: + items.append((f"W:{wind_speed:.0f}g{wind_gust:.0f}{wind_dir}", + self.COLORS['dim'], None)) + else: + items.append((f"W:{wind_speed:.0f}{wind_dir}", self.COLORS['dim'], None)) + + # No degree symbol below — the bottom-bar font can't render it. + if self.show_feels_like and feels_like is not None: + items.append((f"FL:{int(feels_like)}", self.COLORS['dim'], None)) + if self.show_dew_point and dew_point is not None: + items.append((f"Dew:{int(dew_point)}", self.COLORS['dim'], 'dew_point')) + if self.show_visibility and visibility_m is not None: + vis_val = visibility_m / 1609.34 if self.units == 'imperial' else visibility_m / 1000 + vis_u = "mi" if self.units == 'imperial' else "km" + items.append((f"Vis:{vis_val:.0f}{vis_u}", self.COLORS['dim'], 'visibility')) + if self.show_pressure and pressure is not None: + if self.units == 'imperial': + items.append((f"P:{pressure * 0.02953:.2f}\"", self.COLORS['dim'], 'pressure')) + else: + items.append((f"P:{int(pressure)}hPa", self.COLORS['dim'], 'pressure')) + return items + def _display_current_weather(self) -> None: """Display current weather conditions using comprehensive layout with icons.""" try: @@ -1227,13 +1252,18 @@ def _get_daily_state(self) -> List[Dict[str, Any]]: for f in self.daily_forecast[:4] ] - def _render_hourly_forecast_image(self) -> Optional[Image.Image]: - """Render hourly forecast to an Image without display side effects.""" + def _render_hourly_forecast_image(self, width: Optional[int] = None) -> Optional[Image.Image]: + """Render hourly forecast to an Image without display side effects. + + width defaults to the panel. The Vegas ticker passes a narrower one: + columns are laid out as `width // count`, so on a wide panel four + hours get ~128px each to hold ~36px of content. + """ try: if not self.hourly_forecast: return None - width = self.display_manager.matrix.width + width = width or self.display_manager.matrix.width height = self.display_manager.matrix.height img = Image.new('RGB', (width, height), (0, 0, 0)) draw = ImageDraw.Draw(img) @@ -1299,13 +1329,18 @@ def _display_hourly_forecast(self) -> None: except Exception as e: self.logger.error(f"Error displaying hourly forecast: {e}") - def _render_daily_forecast_image(self) -> Optional[Image.Image]: - """Render daily forecast to an Image without display side effects.""" + def _render_daily_forecast_image(self, width: Optional[int] = None) -> Optional[Image.Image]: + """Render daily forecast to an Image without display side effects. + + width defaults to the panel. The Vegas ticker passes a narrower one: + three days spread across a 512px panel leaves ~136px of black between + each column. + """ try: if not self.daily_forecast: return None - width = self.display_manager.matrix.width + width = width or self.display_manager.matrix.width height = self.display_manager.matrix.height img = Image.new('RGB', (width, height), (0, 0, 0)) draw = ImageDraw.Draw(img) @@ -1814,6 +1849,99 @@ def reset_cycle_state(self) -> None: if fetcher is not None: fetcher.reset_loop() + def _compact_forecast_width(self, columns: int) -> Optional[int]: + """Width a forecast needs for `columns`, rather than the whole panel. + + The forecast renderers lay out columns as `width // count`, so they + inherit whatever width they are given: on a 512px panel three daily + columns are ~136px of black apart, and the tile measures 20% inked. + Sizing to the content is what stops the ticker scrolling through that. + + Returns None to mean "use the panel width" when packing would not + actually be narrower -- a small display is already tight. + """ + if columns <= 0: + return None + + layout = self._get_layout() + # A column holds an icon above a short label and temperature; the icon + # is the wider of the two in every size we render at. + column_w = max(layout['forecast_icon_size'], 28) + 8 + compact = column_w * columns + + panel_w = self.display_manager.matrix.width + if compact >= panel_w: + return None + return compact + + def _compact_current_width(self) -> Optional[int]: + """Width the current-conditions layout needs, rather than the panel. + + Measures the two things that set it: the condition/temperature/high-low + stack that right-aligns after the icon, and the metrics bar, which + divides the width into equal sections and so needs room for its widest + item in every one of them. + + Returns None for "use the panel width" when packing would not be + narrower. + """ + try: + layout = self._get_layout() + small = self.display_manager.small_font + tiny = self.display_manager.extra_small_font + measure = ImageDraw.Draw(Image.new('RGB', (1, 1))) + + main = self.weather_data['main'] + temp = int(main['temp']) + condition = self.weather_data['weather'][0]['main'] + stack_w = max( + measure.textlength(condition, font=small), + measure.textlength(f"{temp}\u00b0", font=small), + measure.textlength( + f"{int(main['temp_min'])}\u00b0/{int(main['temp_max'])}\u00b0", + font=small), + ) + gap = max(4, layout['right_margin'] * 2) + text_block = (layout['current_icon_x'] + layout['current_icon_size'] + + gap + int(stack_w) + layout['right_margin']) + + items = self._build_metric_items( + main.get('uvi', 0), main['humidity'], + self.weather_data['wind'].get('speed', 0), + self._get_wind_direction(self.weather_data['wind'].get('deg', 0)), + self.weather_data['wind'].get('gust'), main.get('feels_like'), + main.get('dew_point'), main.get('visibility'), main.get('pressure')) + metrics_block = 0 + if items: + widest = max(measure.textlength(t, font=tiny) for t, _c, _d in items) + # The bar centres every item in an equal section, so the gap + # left between neighbours is (section - widest). Sizing to the + # text plus a few pixels squeezed that to 15px against the + # 63px of the full-width bar, which is legible in a still + # image and tiring to read scrolling past. Reserve a real gap + # instead and give back the width it costs. + metrics_block = (int(widest) + self.COMPACT_METRIC_GAP_PX) * len(items) + + compact = max(text_block, metrics_block) + panel_w = self.display_manager.matrix.width + if compact >= panel_w: + return None + return compact + except Exception as e: + self.logger.debug("Could not size the compact current tile: %s", e) + return None + + def _render_current_weather_vegas_image(self) -> Optional[Image.Image]: + """Current conditions for the Vegas ticker: the same layout, packed. + + Reusing the real renderer at a narrower width keeps the tile the + familiar screen -- icon left, condition over temperature over + high/low, metrics along the bottom -- instead of a second layout that + merely resembles it and drifts. Everything right-aligns to the width it + is handed, so the only change is how much black sits between the parts. + """ + return self._render_current_weather_image(width=self._compact_current_width()) + def get_vegas_content(self): """Return images for all enabled weather display modes.""" if not self.weather_data: @@ -1822,17 +1950,19 @@ def get_vegas_content(self): images = [] if self.show_current: - img = self._render_current_weather_image() + img = self._render_current_weather_vegas_image() if img: images.append(img) if self.show_hourly and self.hourly_forecast: - img = self._render_hourly_forecast_image() + img = self._render_hourly_forecast_image( + width=self._compact_forecast_width(min(4, len(self.hourly_forecast)))) if img: images.append(img) if self.show_daily and self.daily_forecast: - img = self._render_daily_forecast_image() + img = self._render_daily_forecast_image( + width=self._compact_forecast_width(min(3, len(self.daily_forecast)))) if img: images.append(img) diff --git a/plugins/ledmatrix-weather/manifest.json b/plugins/ledmatrix-weather/manifest.json index 6825e388..289ef259 100644 --- a/plugins/ledmatrix-weather/manifest.json +++ b/plugins/ledmatrix-weather/manifest.json @@ -1,7 +1,7 @@ { "id": "ledmatrix-weather", "name": "Weather Display", - "version": "2.6.2", + "version": "2.6.3", "author": "ChuckBuilds", "class_name": "WeatherPlugin", "update_interval": 60, @@ -26,6 +26,12 @@ "radar" ], "versions": [ + { + "version": "2.6.3", + "released": "2026-08-05", + "notes": "Size the Vegas weather tiles to their content instead of the panel. Each mode rendered at full display width, so the ticker scrolled three display widths of mostly black -- 49% inked for current conditions, 28% hourly, 20% daily, with 91px and 136px of dead space between forecast columns, and auto_trim could reclaim only 14% because the ink reaches both edges even where the middle is empty. All three renderers now take an optional width, defaulting to the panel exactly as before, and the Vegas path passes one measured from the content; the layouts are unchanged, just drawn closer together. On a 512px panel the tiles come to 265+272+204px instead of 1536px. The metrics bar reserves a real gap between items rather than packing to the text, because the bar centres each item in an equal section: sizing to the text alone left about 6px between them against 79px at full width. Metric items are built by one helper shared with the full-screen path, keeping the show_* toggles and the UV severity colour in agreement. Where packing would not be narrower each tile falls back to the panel-width render. Normal rotation is pixel-identical for all five modes at 128x32, 256x64 and 512x64.", + "ledmatrix_min_version": "2.0.0" + }, { "released": "2026-07-21", "version": "2.6.2", @@ -41,7 +47,7 @@ { "released": "2026-07-19", "version": "2.6.0", - "notes": "Radar overhaul. (1) Real map tiles: the radar now draws over an OpenStreetMap basemap (self-hosted tile server supported via radar_tile_server, public mirrors as fallback; carto/carto_dark/esri styles too), with the classic WeatherStar vector map kept as a selectable style and as automatic fallback when tiles are unavailable — radar is no longer blank outside the US. (2) Accuracy fix: basemap and radar are now rendered through one shared Web-Mercator viewport and the radar is a mosaic of every tile in view, so precipitation finally lines up with the map on all panel sizes (previously the radar was composited at a different zoom than the map — up to 4x off on 64x32 — and cut off near tile edges). (3) Fresher data: optional RainViewer nowcast frames (~30 min of predicted radar, labeled FCST +Nm with yellow progress dots), index polled every 3 min by default with tiles only downloaded for new frames, and frames/tiles cached to disk so restarts rebuild the animation without refetching. (4) Easier config: new radar_range_miles (distance to panel edge) replaces the abstract radar_zoom (still honored for existing configs), plus map style/brightness, frame timing, and past-frame-count settings. (5) Smoother playback: time-based frame stepping with a hold on the newest frame, and optional dynamic duration to let the rotation wait for a full loop.", + "notes": "Radar overhaul. (1) Real map tiles: the radar now draws over an OpenStreetMap basemap (self-hosted tile server supported via radar_tile_server, public mirrors as fallback; carto/carto_dark/esri styles too), with the classic WeatherStar vector map kept as a selectable style and as automatic fallback when tiles are unavailable \u2014 radar is no longer blank outside the US. (2) Accuracy fix: basemap and radar are now rendered through one shared Web-Mercator viewport and the radar is a mosaic of every tile in view, so precipitation finally lines up with the map on all panel sizes (previously the radar was composited at a different zoom than the map \u2014 up to 4x off on 64x32 \u2014 and cut off near tile edges). (3) Fresher data: optional RainViewer nowcast frames (~30 min of predicted radar, labeled FCST +Nm with yellow progress dots), index polled every 3 min by default with tiles only downloaded for new frames, and frames/tiles cached to disk so restarts rebuild the animation without refetching. (4) Easier config: new radar_range_miles (distance to panel edge) replaces the abstract radar_zoom (still honored for existing configs), plus map style/brightness, frame timing, and past-frame-count settings. (5) Smoother playback: time-based frame stepping with a hold on the newest frame, and optional dynamic duration to let the rotation wait for a full loop.", "ledmatrix_min": "2.0.0" }, { @@ -65,7 +71,7 @@ { "released": "2026-06-10", "version": "2.5.0", - "note": "Geocode the configured location once and cache the coordinates permanently across update cycles and restarts, instead of re-resolving every refresh. Cities don't move, so the geocoding API is now only ever called on a cache miss — eliminating the per-refresh geocoding timeouts that previously aborted the whole weather update, blanked the widget, and triggered up-to-an-hour error backoff. Add optional location_latitude/location_longitude config fields to skip geocoding entirely.", + "note": "Geocode the configured location once and cache the coordinates permanently across update cycles and restarts, instead of re-resolving every refresh. Cities don't move, so the geocoding API is now only ever called on a cache miss \u2014 eliminating the per-refresh geocoding timeouts that previously aborted the whole weather update, blanked the widget, and triggered up-to-an-hour error backoff. Add optional location_latitude/location_longitude config fields to skip geocoding entirely.", "ledmatrix_min": "2.0.0" }, { @@ -144,7 +150,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-07-19", + "last_updated": "2026-08-05", "stars": 0, "downloads": 0, "verified": true,