From 219ab5979521d6103cb649604f40c267cbf23289 Mon Sep 17 00:00:00 2001 From: Carlos de Huerta Date: Fri, 16 Apr 2021 20:26:46 +0200 Subject: [PATCH 01/91] changes for a 2.1.1 version of Pi Weather Rock with Internationalization and Localization through a new file and a ui:lang property in the config file --- forecast-mock.json | 0 piweatherrock/intl.py | 44 ++++ piweatherrock/piweatherrock.lang.json | 201 ++++++++++++++++++ piweatherrock/plugin_info/__init__.py | 32 ++- .../plugin_weather_common/__init__.py | 42 ++-- .../piweatherrock.lang.json | 20 ++ .../plugin_weather_daily/__init__.py | 15 +- piweatherrock/pwr-config-upgrade | 124 +++++++++++ piweatherrock/pwr-ui | 26 +++ piweatherrock/weather.py | 17 +- requirements.txt | 1 + screenshot.jpeg | Bin 0 -> 165073 bytes setup.py | 1 + version.py | 2 +- weather-mock.json | 0 15 files changed, 489 insertions(+), 36 deletions(-) create mode 100644 forecast-mock.json create mode 100644 piweatherrock/intl.py create mode 100644 piweatherrock/piweatherrock.lang.json create mode 100644 piweatherrock/plugin_weather_common/piweatherrock.lang.json create mode 100644 piweatherrock/pwr-config-upgrade create mode 100755 piweatherrock/pwr-ui create mode 100644 screenshot.jpeg create mode 100644 weather-mock.json diff --git a/forecast-mock.json b/forecast-mock.json new file mode 100644 index 0000000..e69de29 diff --git a/piweatherrock/intl.py b/piweatherrock/intl.py new file mode 100644 index 0000000..56c0f98 --- /dev/null +++ b/piweatherrock/intl.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021 Carlos de Huerta +# Distributed under the MIT License (https://opensource.org/licenses/MIT) + +import json +import babel + +from datetime import date, datetime, time +from babel.dates import format_date, format_datetime, format_time +from os import path + +RESOURCES_FILE = 'piweatherrock.lang.json' + +class intl: + """ + This class assists in the internationalization and localization Pi Weather Rock data + through the text stored in the RESOURCES_FILE for different languages supported by the config file. + and several methods for date and time information. + """ + + def __init__(self): + with open(path.join(path.dirname(__file__),RESOURCES_FILE), "r") as t: + self.resources = json.load(t) + + def get_weekday(self, ui_lang, date): + return format_date(date,"EEEE",locale='%s' % ui_lang).capitalize() + + def get_datetime(self, ui_lang, datetime, twelvehr): + if twelvehr is True: + return format_datetime(datetime, "EEE, MMM dd HH:mm", locale='%s' % ui_lang).title() + else: + return format_datetime(datetime, "EEE, MMM dd hh:mm", locale='%s' % ui_lang).title() + + def get_ampm(self, ui_lang, datetime): + return format_datetime(datetime, "a", locale='%s' % ui_lang) + + def get_text(self, ui_lang, text, capital = False, fallback = 'en'): + if self.resources.get(ui_lang) is None: + ui_lang = fallback + + if capital is True: + return self.resources[ui_lang][text].capitalize() + else: + return self.resources[ui_lang][text] \ No newline at end of file diff --git a/piweatherrock/piweatherrock.lang.json b/piweatherrock/piweatherrock.lang.json new file mode 100644 index 0000000..79af9fd --- /dev/null +++ b/piweatherrock/piweatherrock.lang.json @@ -0,0 +1,201 @@ +{ + "ar":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "az":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "be":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "bg":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "bn":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "bs":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "ca":{ + "feels_like": "Sensació tèrmica:", + "wind": "Vent:", + "humidity": "Humitat:", + "umbrella": "¡Agafa el paraigües!", + "no_umbrella": "Avui no agafis el paraigües", + "today": "avui", + "powered_by": "Weather rock gràcies a Dark Sky", + "tonight": "aquesta nit", + "tomorrow": "demà", + "check_at": "Part meteorològic de les", + "sunrise": "Alba: {sunrise}", + "sunset": "Posta de sol: {sunset}", + "sunrise_at": "fa de dia a {hour} hrs {minute:02d} min", + "sunset_at": "Ocàs a {hour} hrs {minute:02d} min", + "daylight": "Llum de dia: {hour} hrs {minute:02d} min" + }, + "cs":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "da":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "de":{ + "feels_like": "Fühlt sich an wie:", + "wind": "Wind:", + "humidity": "Luftfeuchtigkeit:", + "umbrella": "Schnapp dir den Regenschirm!", + "no_umbrella": "Nimm heute nicht den Regenschirm", + "today": "heute", + "powered_by": "Weather rock dank Dark Sky", + "tonight": "heute Abend", + "tomorrow":"morgen", + "check_at": "Wetterbericht der", + "sunrise": "Sonnenaufgang: {sunrise}", + "sunset": "Sonnenuntergang: {sunset}", + "sunrise_at": "Sonnenaufgang in {hour} std. {minute:02d} min.", + "sunset_at": "Sonnenuntergang in {hour} std. {minute:02d} min.", + "daylight": "Tageslicht: {hour} Std. {minute:02d} min." + }, + "el":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "en": { + "feels_like": "Feels Like:", + "wind":"Wind:", + "humidity":"Humidity:", + "umbrella":"Grab your umbrella!", + "no_umbrella":"No umbrella needed today.", + "today":"today", + "powered_by":"A weather rock powered by Dark Sky", + "tonight":"tonight", + "tomorrow":"tomorrow", + "check_at":"Weather checked at", + "sunrise":"Sunrise: {sunrise}", + "sunset":"Sunset: {sunset}", + "sunrise_at":"Sunrise in {hour} hrs {minute:02d} min", + "sunset_at":"Sunset in {hour} hrs {minute:02d} min", + "daylight":"Daylight: {hour} hrs {minute:02d} min" + }, + "eo":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "es": { + "feels_like": "Sensación térmica:", + "wind":"Viento:", + "humidity":"Humedad:", + "umbrella":"¡Coge el paragüas!", + "no_umbrella":"Hoy no cojas el paragüas", + "today":"hoy", + "powered_by":"Weather rock gracias a Dark Sky", + "tonight":"esta noche", + "tomorrow":"mañana", + "check_at":"Parte meteorológico de las", + "sunrise":"Amanecer: {sunrise}", + "sunset":"Puesta de sol: {sunset}", + "sunrise_at":"Amanece en {hour} hrs {minute:02d} min", + "sunset_at":"Ocaso en {hour} hrs {minute:02d} min", + "daylight":"Luz de día: {hour} hrs {minute:02d} min" + }, + "et":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "eu":{ + "feels_like": "Sentitzen da:", + "wind": "Haizea:", + "humidity": "Hezetasuna:", + "umbrella": "Hartu aterkia!", + "no_umbrella": "Gaur ez hartu aterkia", + "today": "gaur", + "powered_by": "Weather rock Dark Sky-ri esker", + "tonight": "gaur gauean", + "tomorrow": "bihar", + "check_at": "Eguraldiaren iragarpena", + "sunrise": "Egunsentia: {sunrise}", + "sunset": "Ilunabarra: {sunset}", + "sunrise_at": "Egunsentia {hour} hrs {minute:02d} min", + "sunset_at": "Ilunabarra {hour} hrs {minute:02d} min", + "daylight": "Eguneko argia: {hour} hrs {minute:02d} min" + }, + "fi":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "fr":{ + "feels_like": "Refroidissement éolien:", + "wind": "Vent:", + "humidity": "Humidité:", + "umbrella": "Attrape le parapluie!", + "no_umbrella": "Ne prenez pas le parapluie aujourd'hui", + "today": "aujourd'hui", + "powered_by": "Weather rock grâce à Dark Sky", + "tonight":"ce soir", + "tomorrow": "demain", + "check_at": "Bulletin météo du", + "sunrise": "Lever de soleil: {sunrise}", + "sunset": "Coucher de soleil: {sunset}", + "sunrise_at": "Lever de soleil dans {hour} hrs {minute:02d} min", + "sunset_at": "Coucher de soleil dans {hour} hrs {minute:02d} min", + "daylight": "Lumière du joir: {hour} hrs {minute:02d} min" + }, + "gl":{ + "feels_like": "Refrixeración do vento:", + "wind": "Vento:", + "moist": "Humidade:", + "umbrella": "Agarra o paraugas!", + "no_umbrella": "Non collas o paraugas hoxe", + "today": "hoxe", + "powered_by": "O tempo é rockeiro grazas a Dark Sky", + "tonight": "esta noite", + "mañá": "mañá", + "check_at": "Informe meteorolóxico do", + "sunrise": "Amanecer: {sunrise}", + "sunset": "Atardecer: {sunset}", + "sunrise_at": "Amencer en {hour} hrs {minute:02d} min", + "sunset_at": "Atardecer en {hour} hrs {minute:02d} min", + "daylight": "Luz do día: {hour} hrs {minute:02d} min" + }, + "he":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "hi":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "hr":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "hu":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "id":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "is":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "it":{ + "feels_like": "Si sente come:", + "wind": "Vento:", + "humidity": "Umidità:", + "umbrella": "Prendi l'ombrello!", + "no_umbrella": "Non prendere l'ombrello oggi", + "today": "today", + "powered_by": "Weather rock grazie a Dark Sky", + "tonight": "stasera", + "tomorrow": "domani", + "check_at": "Bollettino meteorologico del", + "sunrise": "Alba: {sunrise}", + "sunset": "Tramonto: {sunset}", + "sunrise_at": "Alba tra {hour} ore {minute:02d} min", + "sunset_at": "Tramonto tra {hour} ore {minute:02d} min", + "daylight": "Luce del giorno: {hour} ore {minute:02d} min" + }, + "ja":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "ka":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "kn":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "ko":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "kw":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "lv":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "ml":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "mr":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "nb":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "nl":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "no":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "pa":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "pl":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "pt":{ + "feels_like": "Parece:", + "wind": "Vento:", + "humidity": "Umidade:", + "umbrella": "¡Pegue o guarda-chuva!", + "no_umbrella": "Não leve o guarda-chuva hoje", + "today": "hoje", + "powered_by": "Weather rock graças ao Dark Sky", + "tonight": "esta noite", + "tomorrow": "amanhã", + "check_at": "Boletim meteorológico de", + "sunrise": "Nascer do sol: {sunrise}", + "sunset": "Pôr do sol: {sunset}", + "sunrise_at": "Nascer do sol em {hour} horas {minute:02d} min", + "sunset_at": "Pôr do sol em {hour} horas {minute:02d} min", + "daylight": "Luz do dia: {hour} horas {minute:02d} min" + }, + "ro":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "ru":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "sk":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "sl":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "sr":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "sv":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "ta":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "te":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "tet":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "tr":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "uk":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "ur":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "x-pig-latin":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "zh":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "zh-tw":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""} +} diff --git a/piweatherrock/plugin_info/__init__.py b/piweatherrock/plugin_info/__init__.py index 8148c29..baaaeab 100644 --- a/piweatherrock/plugin_info/__init__.py +++ b/piweatherrock/plugin_info/__init__.py @@ -7,6 +7,10 @@ import pygame import time +# local imports +from piweatherrock.intl import intl +from piweatherrock.plugin_weather_common import PluginWeatherCommon + class PluginInfo: """ @@ -30,7 +34,10 @@ def __init__(self, weather_rock): self.time_date_small_y_position = None self.sunrise_string = None self.sunset_string = None - + self.weather_common = None + self.intl = None + self.ui_lang = None + self.get_rock_values(weather_rock) def get_rock_values(self, weather_rock): @@ -46,6 +53,11 @@ def get_rock_values(self, weather_rock): self.time_date_small_y_position = weather_rock.time_date_small_y_position self.sunrise_string = weather_rock.sunrise_string self.sunset_string = weather_rock.sunset_string + self.weather_common = PluginWeatherCommon(weather_rock) + + #Initialize locale resources + self.intl = intl() + self.ui_lang = self.config["ui_lang"] def disp_info(self, weather_rock): self.get_rock_values(weather_rock) @@ -103,33 +115,33 @@ def disp_info(self, weather_rock): (tp + tx1 + 3, self.time_date_small_y_position)) self.string_print( - "A weather rock powered by Dark Sky", small_font, + self.intl.get_text(self.ui_lang,"powered_by"), small_font, self.xmax * 0.05, 3, text_color) self.string_print( - "Sunrise: %s" % self.sunrise_string, + self.intl.get_text(self.ui_lang,"sunrise").format(sunrise=self.sunrise_string), small_font, self.xmax * 0.05, 4, text_color) self.string_print( - "Sunset: %s" % self.sunset_string, + self.intl.get_text(self.ui_lang,"sunset").format(sunset=self.sunset_string), small_font, self.xmax * 0.05, 5, text_color) - text = "Daylight: %d hrs %02d min" % (day_hrs, day_mins) + text = self.intl.get_text(self.ui_lang,"daylight").format(hour=day_hrs, minute=day_mins) self.string_print(text, small_font, self.xmax * 0.05, 6, text_color) # leaving row 7 blank if in_daylight: - text = "Sunset in %d hrs %02d min" % self.stot( - delta_seconds_til_dark) + (sunset_hour, sunset_minute) = self.stot(delta_seconds_til_dark) + text = self.intl.get_text(self.ui_lang,"sunset_at").format(hour=sunset_hour, minute=sunset_minute) else: - text = "Sunrise in %d hrs %02d min" % self.stot( - seconds_til_daylight) + (sunrise_hour, sunrise_minute) = self.stot(seconds_til_daylight) + text = self.intl.get_text(self.ui_lang,"sunrise_at").format(hour=sunrise_hour, minute=sunrise_minute) self.string_print(text, small_font, self.xmax * 0.05, 8, text_color) # leaving row 9 blank - text = "Weather checked at" + text = self.intl.get_text(self.ui_lang,"check_at") self.string_print(text, small_font, self.xmax * 0.05, 10, text_color) if self.config["12hour_disp"]: diff --git a/piweatherrock/plugin_weather_common/__init__.py b/piweatherrock/plugin_weather_common/__init__.py index fa2bb6e..bf4b9b0 100644 --- a/piweatherrock/plugin_weather_common/__init__.py +++ b/piweatherrock/plugin_weather_common/__init__.py @@ -3,14 +3,18 @@ # Copyright (c) 2017 Gene Liverman # Distributed under the MIT License (https://opensource.org/licenses/MIT) -import datetime import pygame import time +import json from os import path +from datetime import datetime + +# local imports +from piweatherrock.intl import intl -UNICODE_DEGREE = u'\xb0' +UNICODE_DEGREE = u'\xb0' class PluginWeatherCommon: """ @@ -36,9 +40,11 @@ def __init__(self, weather_rock): self.time_date_small_y_position = None self.subwindow_text_height = None self.icon_size = None - + self.intl = None + self.ui_lang = None + self.get_rock_values(weather_rock) - + def get_rock_values(self, weather_rock): self.screen = weather_rock.screen self.weather = weather_rock.weather @@ -52,6 +58,10 @@ def get_rock_values(self, weather_rock): self.time_date_small_y_position = weather_rock.time_date_small_y_position self.subwindow_text_height = weather_rock.subwindow_text_height self.icon_size = weather_rock.icon_size + + #Initialize locale resources + self.intl = intl() + self.ui_lang = self.config["ui_lang"] def disp_weather_top(self, weather_rock): self.get_rock_values(weather_rock) @@ -69,7 +79,7 @@ def disp_weather_top(self, weather_rock): self.disp_current_temp(font_name, text_color) self.disp_summary() self.display_conditions_line( - 'Feels Like:', int(round(self.weather.apparentTemperature)), + self.intl.get_text(self.ui_lang,"feels_like"), int(round(self.weather.apparentTemperature)), True) try: @@ -81,18 +91,18 @@ def disp_weather_top(self, weather_rock): int(round(self.weather.windSpeed))) + \ ' ' + self.get_windspeed_abbreviation(self.config["units"]) self.display_conditions_line( - 'Wind:', wind_txt, False, 1) + self.intl.get_text(self.ui_lang,"wind"), wind_txt, False, 1) self.display_conditions_line( - 'Humidity:', str(int(round((self.weather.humidity * 100)))) + '%', + self.intl.get_text(self.ui_lang,"humidity"), str(int(round((self.weather.humidity * 100)))) + '%', False, 2) # Skipping multiplier 3 (line 4) if self.take_umbrella: - umbrella_txt = 'Grab your umbrella!' + umbrella_txt = self.intl.get_text(self.ui_lang,"umbrella") else: - umbrella_txt = 'No umbrella needed today.' + umbrella_txt = self.intl.get_text(self.ui_lang,"no_umbrella") self.disp_umbrella_info(umbrella_txt) def draw_screen_border(self, line_color, xmin, lines): @@ -138,10 +148,10 @@ def disp_time_date(self, font_name, text_color): int(self.ymax * self.time_date_small_text_height), bold=1) if self.config["12hour_disp"]: - time_string = time.strftime("%a, %b %d %I:%M", time.localtime()) - am_pm_string = time.strftime(" %p", time.localtime()) + time_string = self.intl.get_datetime(self.ui_lang, datetime.utcnow(), True) + am_pm_string = self.intl.get_ampm(self.ui_lang, datetime.utcnow()) else: - time_string = time.strftime("%a, %b %d %H:%M", time.localtime()) + time_string = self.intl.get_datetime(self.ui_lang, datetime.utcnow(), False) am_pm_string = "hr" rendered_time_string = time_date_font.render(time_string, True, @@ -269,12 +279,12 @@ def umbrella_needed(self): take_umbrella = True else: # determine if an umbrella is needed during daylight hours - curr_date = datetime.datetime.today().date() + curr_date = datetime.today().date() for hour in self.weather.hourly: - hr = datetime.datetime.fromtimestamp(hour.time) - sr = datetime.datetime.fromtimestamp( + hr = datetime.fromtimestamp(hour.time) + sr = datetime.fromtimestamp( self.weather.daily[0].sunriseTime) - ss = datetime.datetime.fromtimestamp( + ss = datetime.fromtimestamp( self.weather.daily[0].sunsetTime) rain_chance = hour.precipProbability is_today = hr.date() == curr_date diff --git a/piweatherrock/plugin_weather_common/piweatherrock.lang.json b/piweatherrock/plugin_weather_common/piweatherrock.lang.json new file mode 100644 index 0000000..05eb5db --- /dev/null +++ b/piweatherrock/plugin_weather_common/piweatherrock.lang.json @@ -0,0 +1,20 @@ +{ + "en": { + "feels_like": "Feels Like:", + "wind":"Wind:", + "humidity":"Humidity:", + "umbrella":"Grab your umbrella!", + "no_unmbrella":"No umbrella needed today.", + "today":"Today", + "powered_by":"A weather rock powered by Dark Sky" + }, + "es": { + "feels_like": "Parecen:", + "wind":"Viento:", + "humidity":"Humedad:", + "umbrella":"¡Coge el paragüas!", + "no_umbrella":"Hoy no necesitarás el paragüas.", + "today":"Hoy", + "powered_by":"Weather rock gracias a Dark Sky" + } +} diff --git a/piweatherrock/plugin_weather_daily/__init__.py b/piweatherrock/plugin_weather_daily/__init__.py index 79a1df1..7258b85 100644 --- a/piweatherrock/plugin_weather_daily/__init__.py +++ b/piweatherrock/plugin_weather_daily/__init__.py @@ -6,12 +6,14 @@ import datetime import pygame +# local imports +from piweatherrock.intl import intl from piweatherrock.plugin_weather_common import PluginWeatherCommon class PluginWeatherDaily: """ - This plugin is resposible for displaying the screen with the daily + This plugin is responsible for displaying the screen with the daily forecast. """ @@ -19,11 +21,17 @@ def __init__(self, weather_rock): self.screen = None self.weather = None self.weather_common = None + self.intl = None + self.ui_lang = None def get_rock_values(self, weather_rock): self.screen = weather_rock.screen self.weather = weather_rock.weather self.weather_common = PluginWeatherCommon(weather_rock) + + #Initialize locale resources + self.intl = intl() + self.ui_lang = self.weather_common.config["ui_lang"] def disp_daily(self, weather_rock): self.get_rock_values(weather_rock) @@ -32,7 +40,7 @@ def disp_daily(self, weather_rock): # Today today = self.weather.daily[0] - today_string = "Today" + today_string = self.intl.get_text(self.ui_lang,"today", True) multiplier = 1 self.weather_common.display_subwindow(today, today_string, multiplier) @@ -40,10 +48,9 @@ def disp_daily(self, weather_rock): for future_day in range(3): this_day = self.weather.daily[future_day + 1] this_day_no = datetime.datetime.fromtimestamp(this_day.time) - this_day_string = this_day_no.strftime("%A") multiplier += 2 self.weather_common.display_subwindow( - this_day, this_day_string, multiplier) + this_day, self.intl.get_weekday(self.ui_lang, this_day_no), multiplier) # Update the display pygame.display.update() diff --git a/piweatherrock/pwr-config-upgrade b/piweatherrock/pwr-config-upgrade new file mode 100644 index 0000000..9114be2 --- /dev/null +++ b/piweatherrock/pwr-config-upgrade @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# Copyright (c) 2020 Gene Liverman +# Distributed under the MIT License (https://opensource.org/licenses/MIT) + +############################################################################### +# Raspberry Pi Weather Display Config Page Plugin +# Original By: github user: metaMMA 2020-03-15 +############################################################################### + +import json +import os +import socket + +from argparse import ArgumentParser + +pi_ip = socket.gethostbyname(socket.gethostname() + ".local") + + +def migrate_to_json_config(config_location): + print(f"\nImporting current configuration settings.\n\n" + f"Go to http://{pi_ip}:8888 to view new configuration interface.\n") + + # cd to the folder where config.py resides + config_dir = os.path.dirname(os.path.abspath(config_location)) + os.chdir(config_dir) + + # import the old config + import config + + old_config = {} + old_config["ds_api_key"] = config.DS_API_KEY + old_config["update_freq"] = int(config.DS_CHECK_INTERVAL) + old_config["lat"] = float(config.LAT) + old_config["lon"] = float(config.LON) + old_config["units"] = config.UNITS + old_config["lang"] = config.LANG + old_config["fullscreen"] = config.FULLSCREEN + old_config["icon_offset"] = float(config.LARGE_ICON_OFFSET) + old_config["plugins"] = {} + old_config["plugins"]["daily"] = {} + old_config["plugins"]["hourly"] = {} + old_config["plugins"]["daily"]["enabled"] = True + old_config["plugins"]["hourly"]["enabled"] = True + if hasattr(config, "DAILY_PAUSE"): + old_config["plugins"]["daily"]["pause"] = int(config.DAILY_PAUSE) + else: + old_config["plugins"]["daily"]["pause"] = 60 + if hasattr(config, "HOURLY_PAUSE"): + old_config["plugins"]["hourly"]["pause"] = int(config.HOURLY_PAUSE) + else: + old_config["plugins"]["hourly"]["pause"] = 60 + if hasattr(config, "INFO_PAUSE"): + old_config["info_pause"] = int(config.INFO_PAUSE) + else: + old_config["info_pause"] = 300 + if hasattr(config, "INFO_DELAY"): + old_config["info_delay"] = int(config.INFO_DELAY) + else: + old_config["info_delay"] = 900 + os.remove("config.py") + + # get out of the git repo since its not used any more + script_dir = os.path.dirname(os.path.abspath(__file__)) + os.chdir(script_dir) + + return old_config + + +def main(): + parser = ArgumentParser( + """ + Creates or updates a configuration file. + """) + parser.add_argument( + '-c', '--config', required=True, + help='Path to your config.json file') + parser.add_argument( + '-o', '--oldconfig', required=False, + help='Path to your old config.py file') + parser.add_argument( + '-s', '--sample', required=True, + help=""" + Path to config.json-sample. + This file is included automatically when installing via pip. + You can locate it with the 'find' command like so: + find /usr/local -type f -name config.json-sample + """) + + args = parser.parse_args() + config_file = os.path.abspath(args.config) + sample_file = os.path.abspath(args.sample) + + if args.oldconfig is not None and os.path.exists(args.oldconfig): + old_config_file = os.path.abspath(args.oldconfig) + old_config = migrate_to_json_config(old_config_file) + + elif os.path.exists('/home/pi/config.py'): + old_config = migrate_to_json_config('/home/pi/config.py') + + elif os.path.exists(config_file): + with open(config_file, "r") as f: + old_config = json.load(f) + + elif os.path.exists(sample_file): + with open(sample_file, "r") as f: + old_config = json.load(f) + print(f"\nYou must configure PiWeatherRock.\n\n" + f"Go to http://{pi_ip}:8888 to configure.\n") + + with open(sample_file, "r") as f: + new_config = json.load(f) + + # Add any new config variables + for key in new_config.keys(): + if key not in old_config.keys(): + old_config[key] = new_config[key] + + with open(config_file, "w") as f: + json.dump(old_config, f) + + +if __name__ == '__main__': + main() diff --git a/piweatherrock/pwr-ui b/piweatherrock/pwr-ui new file mode 100755 index 0000000..61656c4 --- /dev/null +++ b/piweatherrock/pwr-ui @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# Copyright (c) 2020 Gene Liverman +# Distributed under the MIT License (https://opensource.org/licenses/MIT) + +import os +from argparse import ArgumentParser +from piweatherrock.runner import Runner + + +def main(): + parser = ArgumentParser( + """Runs the PiWeatherRock UI""") + parser.add_argument( + '-c', '--config', required=True, + help='Path to your config file') + + args = parser.parse_args() + config = os.path.abspath(args.config) + + runner = Runner() + runner.main(config) + + +if __name__ == '__main__': + main() diff --git a/piweatherrock/weather.py b/piweatherrock/weather.py index 587289c..2e299cb 100644 --- a/piweatherrock/weather.py +++ b/piweatherrock/weather.py @@ -19,6 +19,9 @@ import pygame import requests +# local imports +from piweatherrock.intl import intl + # globals UNICODE_DEGREE = u'\xb0' @@ -39,6 +42,10 @@ def __init__(self, config_file): with open(config_file, "r") as f: self.config = json.load(f) + #Initialize locale intl + self.intl = intl() + self.ui_lang = self.config["ui_lang"] + self.last_update_check = 0 self.weather = {} self.get_forecast() @@ -155,17 +162,17 @@ def get_forecast(self): exclude='minutely', units=self.config["units"], lang=self.config["lang"]) - + sunset_today = datetime.datetime.fromtimestamp( self.weather.daily[0].sunsetTime) if datetime.datetime.now() < sunset_today: index = 0 - sr_suffix = 'today' - ss_suffix = 'tonight' + sr_suffix = self.intl.get_text(self.ui_lang,"today") + ss_suffix = self.intl.get_text(self.ui_lang,"tonight") else: index = 1 - sr_suffix = 'tomorrow' - ss_suffix = 'tomorrow' + sr_suffix = self.intl.get_text(self.ui_lang,"tomorrow") + ss_suffix = self.intl.get_text(self.ui_lang,"tomorrow") self.sunrise = self.weather.daily[index].sunriseTime self.sunset = self.weather.daily[index].sunsetTime diff --git a/requirements.txt b/requirements.txt index cf64753..718fb31 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,5 +3,6 @@ pygame pyserial requests cherrypy +babel piweatherrock-webconfig==1.5.0 diff --git a/screenshot.jpeg b/screenshot.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..082286fc86e67b800b8c55c109a5c5f508375f3b GIT binary patch literal 165073 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

>x$ZH)uYher~}s-Q0By1_u|biAUpkQzU+cf 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 Date: Fri, 16 Apr 2021 20:33:40 +0200 Subject: [PATCH 02/91] Delete piweatherrock.lang.json --- .../piweatherrock.lang.json | 20 ------------------- 1 file changed, 20 deletions(-) delete mode 100644 piweatherrock/plugin_weather_common/piweatherrock.lang.json diff --git a/piweatherrock/plugin_weather_common/piweatherrock.lang.json b/piweatherrock/plugin_weather_common/piweatherrock.lang.json deleted file mode 100644 index 05eb5db..0000000 --- a/piweatherrock/plugin_weather_common/piweatherrock.lang.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "en": { - "feels_like": "Feels Like:", - "wind":"Wind:", - "humidity":"Humidity:", - "umbrella":"Grab your umbrella!", - "no_unmbrella":"No umbrella needed today.", - "today":"Today", - "powered_by":"A weather rock powered by Dark Sky" - }, - "es": { - "feels_like": "Parecen:", - "wind":"Viento:", - "humidity":"Humedad:", - "umbrella":"¡Coge el paragüas!", - "no_umbrella":"Hoy no necesitarás el paragüas.", - "today":"Hoy", - "powered_by":"Weather rock gracias a Dark Sky" - } -} From 95f4c9febfa4fd1ca0eed5c2c6c73e5df4c8fb63 Mon Sep 17 00:00:00 2001 From: Carlos Date: Sat, 17 Apr 2021 13:42:10 +0200 Subject: [PATCH 03/91] Delete screenshot.jpeg --- screenshot.jpeg | Bin 165073 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 screenshot.jpeg diff --git a/screenshot.jpeg b/screenshot.jpeg deleted file mode 100644 index 082286fc86e67b800b8c55c109a5c5f508375f3b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 165073 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

>x$ZH)uYher~}s-Q0By1_u|biAUpkQzU+cf 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 Date: Sat, 17 Apr 2021 13:42:21 +0200 Subject: [PATCH 04/91] Delete weather-mock.json --- weather-mock.json | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 weather-mock.json diff --git a/weather-mock.json b/weather-mock.json deleted file mode 100644 index e69de29..0000000 From 8daed5d0323529ba4c6ede625c5f7aeb4de499e7 Mon Sep 17 00:00:00 2001 From: Carlos Date: Sat, 17 Apr 2021 13:42:37 +0200 Subject: [PATCH 05/91] Delete forecast-mock.json --- forecast-mock.json | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 forecast-mock.json diff --git a/forecast-mock.json b/forecast-mock.json deleted file mode 100644 index e69de29..0000000 From ce7d494edbd165d2436c9e3f208c615fe43097b0 Mon Sep 17 00:00:00 2001 From: Carlos Date: Sat, 17 Apr 2021 13:43:22 +0200 Subject: [PATCH 06/91] Delete pwr-config-upgrade --- piweatherrock/pwr-config-upgrade | 124 ------------------------------- 1 file changed, 124 deletions(-) delete mode 100644 piweatherrock/pwr-config-upgrade diff --git a/piweatherrock/pwr-config-upgrade b/piweatherrock/pwr-config-upgrade deleted file mode 100644 index 9114be2..0000000 --- a/piweatherrock/pwr-config-upgrade +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -# Copyright (c) 2020 Gene Liverman -# Distributed under the MIT License (https://opensource.org/licenses/MIT) - -############################################################################### -# Raspberry Pi Weather Display Config Page Plugin -# Original By: github user: metaMMA 2020-03-15 -############################################################################### - -import json -import os -import socket - -from argparse import ArgumentParser - -pi_ip = socket.gethostbyname(socket.gethostname() + ".local") - - -def migrate_to_json_config(config_location): - print(f"\nImporting current configuration settings.\n\n" - f"Go to http://{pi_ip}:8888 to view new configuration interface.\n") - - # cd to the folder where config.py resides - config_dir = os.path.dirname(os.path.abspath(config_location)) - os.chdir(config_dir) - - # import the old config - import config - - old_config = {} - old_config["ds_api_key"] = config.DS_API_KEY - old_config["update_freq"] = int(config.DS_CHECK_INTERVAL) - old_config["lat"] = float(config.LAT) - old_config["lon"] = float(config.LON) - old_config["units"] = config.UNITS - old_config["lang"] = config.LANG - old_config["fullscreen"] = config.FULLSCREEN - old_config["icon_offset"] = float(config.LARGE_ICON_OFFSET) - old_config["plugins"] = {} - old_config["plugins"]["daily"] = {} - old_config["plugins"]["hourly"] = {} - old_config["plugins"]["daily"]["enabled"] = True - old_config["plugins"]["hourly"]["enabled"] = True - if hasattr(config, "DAILY_PAUSE"): - old_config["plugins"]["daily"]["pause"] = int(config.DAILY_PAUSE) - else: - old_config["plugins"]["daily"]["pause"] = 60 - if hasattr(config, "HOURLY_PAUSE"): - old_config["plugins"]["hourly"]["pause"] = int(config.HOURLY_PAUSE) - else: - old_config["plugins"]["hourly"]["pause"] = 60 - if hasattr(config, "INFO_PAUSE"): - old_config["info_pause"] = int(config.INFO_PAUSE) - else: - old_config["info_pause"] = 300 - if hasattr(config, "INFO_DELAY"): - old_config["info_delay"] = int(config.INFO_DELAY) - else: - old_config["info_delay"] = 900 - os.remove("config.py") - - # get out of the git repo since its not used any more - script_dir = os.path.dirname(os.path.abspath(__file__)) - os.chdir(script_dir) - - return old_config - - -def main(): - parser = ArgumentParser( - """ - Creates or updates a configuration file. - """) - parser.add_argument( - '-c', '--config', required=True, - help='Path to your config.json file') - parser.add_argument( - '-o', '--oldconfig', required=False, - help='Path to your old config.py file') - parser.add_argument( - '-s', '--sample', required=True, - help=""" - Path to config.json-sample. - This file is included automatically when installing via pip. - You can locate it with the 'find' command like so: - find /usr/local -type f -name config.json-sample - """) - - args = parser.parse_args() - config_file = os.path.abspath(args.config) - sample_file = os.path.abspath(args.sample) - - if args.oldconfig is not None and os.path.exists(args.oldconfig): - old_config_file = os.path.abspath(args.oldconfig) - old_config = migrate_to_json_config(old_config_file) - - elif os.path.exists('/home/pi/config.py'): - old_config = migrate_to_json_config('/home/pi/config.py') - - elif os.path.exists(config_file): - with open(config_file, "r") as f: - old_config = json.load(f) - - elif os.path.exists(sample_file): - with open(sample_file, "r") as f: - old_config = json.load(f) - print(f"\nYou must configure PiWeatherRock.\n\n" - f"Go to http://{pi_ip}:8888 to configure.\n") - - with open(sample_file, "r") as f: - new_config = json.load(f) - - # Add any new config variables - for key in new_config.keys(): - if key not in old_config.keys(): - old_config[key] = new_config[key] - - with open(config_file, "w") as f: - json.dump(old_config, f) - - -if __name__ == '__main__': - main() From 6105dd5b145ec839f81e43120fcc73cefa4d5b7c Mon Sep 17 00:00:00 2001 From: Carlos Date: Sat, 17 Apr 2021 13:43:28 +0200 Subject: [PATCH 07/91] Delete pwr-ui --- piweatherrock/pwr-ui | 26 -------------------------- 1 file changed, 26 deletions(-) delete mode 100755 piweatherrock/pwr-ui diff --git a/piweatherrock/pwr-ui b/piweatherrock/pwr-ui deleted file mode 100755 index 61656c4..0000000 --- a/piweatherrock/pwr-ui +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -# Copyright (c) 2020 Gene Liverman -# Distributed under the MIT License (https://opensource.org/licenses/MIT) - -import os -from argparse import ArgumentParser -from piweatherrock.runner import Runner - - -def main(): - parser = ArgumentParser( - """Runs the PiWeatherRock UI""") - parser.add_argument( - '-c', '--config', required=True, - help='Path to your config file') - - args = parser.parse_args() - config = os.path.abspath(args.config) - - runner = Runner() - runner.main(config) - - -if __name__ == '__main__': - main() From 7af61d5390dd072acd1ec0847bf528697800c9ec Mon Sep 17 00:00:00 2001 From: Carlos de Huerta Date: Sat, 17 Apr 2021 18:01:52 +0200 Subject: [PATCH 08/91] version 2.2.1 with intl and local with sample for es, eu, gl, en, it, pt, de, fr, ca --- forecast-mock.json | 0 piweatherrock/config.json-sample | 1 + piweatherrock/{intl.py => intl/__init__.py} | 25 ++- piweatherrock/intl/data/piweatherrock.ca.json | 19 +++ piweatherrock/intl/data/piweatherrock.de.json | 19 +++ piweatherrock/intl/data/piweatherrock.en.json | 19 +++ piweatherrock/intl/data/piweatherrock.es.json | 19 +++ piweatherrock/intl/data/piweatherrock.eu.json | 19 +++ piweatherrock/intl/data/piweatherrock.fr.json | 19 +++ piweatherrock/intl/data/piweatherrock.gl.json | 19 +++ piweatherrock/intl/data/piweatherrock.it.json | 19 +++ .../{ => intl/data}/piweatherrock.lang.json | 153 ------------------ piweatherrock/intl/data/piweatherrock.pt.json | 19 +++ piweatherrock/plugin_info/__init__.py | 14 +- .../plugin_weather_common/__init__.py | 15 +- .../plugin_weather_daily/__init__.py | 2 +- piweatherrock/pwr-config-upgrade | 124 -------------- piweatherrock/pwr-ui | 26 --- requirements.txt | 1 + screenshot.jpeg | Bin 165073 -> 0 bytes setup.py | 2 +- version.py | 2 +- weather-mock.json | 0 23 files changed, 204 insertions(+), 332 deletions(-) delete mode 100644 forecast-mock.json rename piweatherrock/{intl.py => intl/__init__.py} (62%) create mode 100644 piweatherrock/intl/data/piweatherrock.ca.json create mode 100644 piweatherrock/intl/data/piweatherrock.de.json create mode 100644 piweatherrock/intl/data/piweatherrock.en.json create mode 100644 piweatherrock/intl/data/piweatherrock.es.json create mode 100644 piweatherrock/intl/data/piweatherrock.eu.json create mode 100644 piweatherrock/intl/data/piweatherrock.fr.json create mode 100644 piweatherrock/intl/data/piweatherrock.gl.json create mode 100644 piweatherrock/intl/data/piweatherrock.it.json rename piweatherrock/{ => intl/data}/piweatherrock.lang.json (66%) create mode 100644 piweatherrock/intl/data/piweatherrock.pt.json delete mode 100644 piweatherrock/pwr-config-upgrade delete mode 100755 piweatherrock/pwr-ui delete mode 100644 screenshot.jpeg delete mode 100644 weather-mock.json diff --git a/forecast-mock.json b/forecast-mock.json deleted file mode 100644 index e69de29..0000000 diff --git a/piweatherrock/config.json-sample b/piweatherrock/config.json-sample index 4bf6d63..468548e 100644 --- a/piweatherrock/config.json-sample +++ b/piweatherrock/config.json-sample @@ -5,6 +5,7 @@ "lon": 0.246810, "units": "us", "lang": "en", + "ui_lang": "en", "fullscreen": true, "icon_offset": -23.5, "update_freq": 300, diff --git a/piweatherrock/intl.py b/piweatherrock/intl/__init__.py similarity index 62% rename from piweatherrock/intl.py rename to piweatherrock/intl/__init__.py index 56c0f98..6948b72 100644 --- a/piweatherrock/intl.py +++ b/piweatherrock/intl/__init__.py @@ -4,23 +4,22 @@ import json import babel +import i18n from datetime import date, datetime, time from babel.dates import format_date, format_datetime, format_time from os import path -RESOURCES_FILE = 'piweatherrock.lang.json' - class intl: """ This class assists in the internationalization and localization Pi Weather Rock data - through the text stored in the RESOURCES_FILE for different languages supported by the config file. - and several methods for date and time information. + through the use of python i18n and Babel. """ def __init__(self): - with open(path.join(path.dirname(__file__),RESOURCES_FILE), "r") as t: - self.resources = json.load(t) + i18n.set('file_format', 'json') + i18n.set('fallback', 'en') + i18n.load_path.append(path.join(path.dirname(__file__),'data')) def get_weekday(self, ui_lang, date): return format_date(date,"EEEE",locale='%s' % ui_lang).capitalize() @@ -34,11 +33,11 @@ def get_datetime(self, ui_lang, datetime, twelvehr): def get_ampm(self, ui_lang, datetime): return format_datetime(datetime, "a", locale='%s' % ui_lang) - def get_text(self, ui_lang, text, capital = False, fallback = 'en'): - if self.resources.get(ui_lang) is None: - ui_lang = fallback - - if capital is True: - return self.resources[ui_lang][text].capitalize() + def get_text(self, ui_lang, text, params = None): + i18n.set('locale', ui_lang) + label = 'piweatherrock.' + text + + if params is None: + return i18n.t(label) else: - return self.resources[ui_lang][text] \ No newline at end of file + return i18n.t(label, **params) \ No newline at end of file diff --git a/piweatherrock/intl/data/piweatherrock.ca.json b/piweatherrock/intl/data/piweatherrock.ca.json new file mode 100644 index 0000000..f3b7255 --- /dev/null +++ b/piweatherrock/intl/data/piweatherrock.ca.json @@ -0,0 +1,19 @@ +{ + "ca":{ + "feels_like": "Sensació tèrmica:", + "wind": "Vent:", + "humidity": "Humitat:", + "umbrella": "¡Agafa el paraigües!", + "no_umbrella": "Avui no agafis el paraigües", + "today": "avui", + "powered_by": "Weather rock gràcies a Dark Sky", + "tonight": "aquesta nit", + "tomorrow": "demà", + "check_at": "Part meteorològic de les", + "sunrise": "Alba: %{sunrise}", + "sunset": "Posta de sol: %{sunset}", + "sunrise_at": "fa de dia a %{hour} hrs %{minute} min", + "sunset_at": "Ocàs a %{hour} hrs %{minute} min", + "daylight": "Llum de dia: %{hour} hrs %{minute} min" + } +} diff --git a/piweatherrock/intl/data/piweatherrock.de.json b/piweatherrock/intl/data/piweatherrock.de.json new file mode 100644 index 0000000..adfc911 --- /dev/null +++ b/piweatherrock/intl/data/piweatherrock.de.json @@ -0,0 +1,19 @@ +{ + "de":{ + "feels_like": "Fühlt sich an wie:", + "wind": "Wind:", + "humidity": "Luftfeuchtigkeit:", + "umbrella": "Schnapp dir den Regenschirm!", + "no_umbrella": "Nimm heute nicht den Regenschirm", + "today": "heute", + "powered_by": "Weather rock dank Dark Sky", + "tonight": "heute Abend", + "tomorrow":"morgen", + "check_at": "Wetterbericht der", + "sunrise": "Sonnenaufgang: %{sunrise}", + "sunset": "Sonnenuntergang: %{sunset}", + "sunrise_at": "Sonnenaufgang in %{hour} std. %{minute} min.", + "sunset_at": "Sonnenuntergang in %{hour} std. %{minute} min.", + "daylight": "Tageslicht: %{hour} Std. %{minute} min." + } +} diff --git a/piweatherrock/intl/data/piweatherrock.en.json b/piweatherrock/intl/data/piweatherrock.en.json new file mode 100644 index 0000000..54de593 --- /dev/null +++ b/piweatherrock/intl/data/piweatherrock.en.json @@ -0,0 +1,19 @@ +{ + "en": { + "feels_like": "Feels Like:", + "wind":"Wind:", + "humidity":"Humidity:", + "umbrella":"Grab your umbrella!", + "no_umbrella":"No umbrella needed today.", + "today":"today", + "powered_by":"A weather rock powered by Dark Sky", + "tonight":"tonight", + "tomorrow":"tomorrow", + "check_at":"Weather checked at", + "sunrise":"Sunrise: %{sunrise}", + "sunset":"Sunset: %{sunset}", + "sunrise_at":"Sunrise in %{hour} hrs %{minute} min", + "sunset_at":"Sunset in %{hour} hrs %{minute} min", + "daylight":"Daylight: %{hour} hrs %{minute} min" + } +} diff --git a/piweatherrock/intl/data/piweatherrock.es.json b/piweatherrock/intl/data/piweatherrock.es.json new file mode 100644 index 0000000..fabc92f --- /dev/null +++ b/piweatherrock/intl/data/piweatherrock.es.json @@ -0,0 +1,19 @@ +{ + "es": { + "feels_like": "Sensación térmica:", + "wind":"Viento:", + "humidity":"Humedad:", + "umbrella":"¡Coge el paragüas!", + "no_umbrella":"Hoy no cojas el paragüas", + "today":"hoy", + "powered_by":"Weather rock gracias a Dark Sky", + "tonight":"esta noche", + "tomorrow":"mañana", + "check_at":"Parte meteorológico de las", + "sunrise":"Amanecer: %{sunrise}", + "sunset":"Puesta de sol: %{sunset}", + "sunrise_at":"Amanece en %{hour} hrs %{minute} min", + "sunset_at":"Ocaso en %{hour} hrs %{minute} min", + "daylight":"Luz de día: %{hour} hrs %{minute} min" + } +} diff --git a/piweatherrock/intl/data/piweatherrock.eu.json b/piweatherrock/intl/data/piweatherrock.eu.json new file mode 100644 index 0000000..263f6da --- /dev/null +++ b/piweatherrock/intl/data/piweatherrock.eu.json @@ -0,0 +1,19 @@ +{ + "eu":{ + "feels_like": "Sentitzen da:", + "wind": "Haizea:", + "humidity": "Hezetasuna:", + "umbrella": "Hartu aterkia!", + "no_umbrella": "Gaur ez hartu aterkia", + "today": "gaur", + "powered_by": "Weather rock Dark Sky-ri esker", + "tonight": "gaur gauean", + "tomorrow": "bihar", + "check_at": "Eguraldiaren iragarpena", + "sunrise": "Egunsentia: %{sunrise}", + "sunset": "Ilunabarra: %{sunset}", + "sunrise_at": "Egunsentia %{hour} hrs %{minute} min", + "sunset_at": "Ilunabarra %{hour} hrs %{minute} min", + "daylight": "Eguneko argia: %{hour} hrs %{minute} min" + } +} diff --git a/piweatherrock/intl/data/piweatherrock.fr.json b/piweatherrock/intl/data/piweatherrock.fr.json new file mode 100644 index 0000000..0a5759b --- /dev/null +++ b/piweatherrock/intl/data/piweatherrock.fr.json @@ -0,0 +1,19 @@ +{ + "fr":{ + "feels_like": "Refroidissement éolien:", + "wind": "Vent:", + "humidity": "Humidité:", + "umbrella": "Attrape le parapluie!", + "no_umbrella": "Ne prenez pas le parapluie aujourd'hui", + "today": "aujourd'hui", + "powered_by": "Weather rock grâce à Dark Sky", + "tonight":"ce soir", + "tomorrow": "demain", + "check_at": "Bulletin météo du", + "sunrise": "Lever de soleil: %{sunrise}", + "sunset": "Coucher de soleil: %{sunset}", + "sunrise_at": "Lever de soleil dans %{hour} hrs %{minute} min", + "sunset_at": "Coucher de soleil dans %{hour} hrs %{minute} min", + "daylight": "Lumière du joir: %{hour} hrs %{minute} min" + } +} diff --git a/piweatherrock/intl/data/piweatherrock.gl.json b/piweatherrock/intl/data/piweatherrock.gl.json new file mode 100644 index 0000000..a77d23b --- /dev/null +++ b/piweatherrock/intl/data/piweatherrock.gl.json @@ -0,0 +1,19 @@ +{ + "gl":{ + "feels_like": "Refrixeración do vento:", + "wind": "Vento:", + "moist": "Humidade:", + "umbrella": "Agarra o paraugas!", + "no_umbrella": "Non collas o paraugas hoxe", + "today": "hoxe", + "powered_by": "O tempo é rockeiro grazas a Dark Sky", + "tonight": "esta noite", + "mañá": "mañá", + "check_at": "Informe meteorolóxico do", + "sunrise": "Amanecer: %{sunrise}", + "sunset": "Atardecer: %{sunset}", + "sunrise_at": "Amencer en %{hour} hrs %{minute} min", + "sunset_at": "Atardecer en %{hour} hrs %{minute} min", + "daylight": "Luz do día: %{hour} hrs %{minute} min" + } +} diff --git a/piweatherrock/intl/data/piweatherrock.it.json b/piweatherrock/intl/data/piweatherrock.it.json new file mode 100644 index 0000000..7ab1532 --- /dev/null +++ b/piweatherrock/intl/data/piweatherrock.it.json @@ -0,0 +1,19 @@ +{ + "it":{ + "feels_like": "Si sente come:", + "wind": "Vento:", + "humidity": "Umidità:", + "umbrella": "Prendi l'ombrello!", + "no_umbrella": "Non prendere l'ombrello oggi", + "today": "today", + "powered_by": "Weather rock grazie a Dark Sky", + "tonight": "stasera", + "tomorrow": "domani", + "check_at": "Bollettino meteorologico del", + "sunrise": "Alba: %{sunrise}", + "sunset": "Tramonto: %{sunset}", + "sunrise_at": "Alba tra %{hour} ore %{minute} min", + "sunset_at": "Tramonto tra %{hour} ore %{minute} min", + "daylight": "Luce del giorno: %{hour} ore %{minute} min" + } +} diff --git a/piweatherrock/piweatherrock.lang.json b/piweatherrock/intl/data/piweatherrock.lang.json similarity index 66% rename from piweatherrock/piweatherrock.lang.json rename to piweatherrock/intl/data/piweatherrock.lang.json index 79af9fd..d8c59ae 100644 --- a/piweatherrock/piweatherrock.lang.json +++ b/piweatherrock/intl/data/piweatherrock.lang.json @@ -5,154 +5,18 @@ "bg":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, "bn":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, "bs":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, - "ca":{ - "feels_like": "Sensació tèrmica:", - "wind": "Vent:", - "humidity": "Humitat:", - "umbrella": "¡Agafa el paraigües!", - "no_umbrella": "Avui no agafis el paraigües", - "today": "avui", - "powered_by": "Weather rock gràcies a Dark Sky", - "tonight": "aquesta nit", - "tomorrow": "demà", - "check_at": "Part meteorològic de les", - "sunrise": "Alba: {sunrise}", - "sunset": "Posta de sol: {sunset}", - "sunrise_at": "fa de dia a {hour} hrs {minute:02d} min", - "sunset_at": "Ocàs a {hour} hrs {minute:02d} min", - "daylight": "Llum de dia: {hour} hrs {minute:02d} min" - }, "cs":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, "da":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, - "de":{ - "feels_like": "Fühlt sich an wie:", - "wind": "Wind:", - "humidity": "Luftfeuchtigkeit:", - "umbrella": "Schnapp dir den Regenschirm!", - "no_umbrella": "Nimm heute nicht den Regenschirm", - "today": "heute", - "powered_by": "Weather rock dank Dark Sky", - "tonight": "heute Abend", - "tomorrow":"morgen", - "check_at": "Wetterbericht der", - "sunrise": "Sonnenaufgang: {sunrise}", - "sunset": "Sonnenuntergang: {sunset}", - "sunrise_at": "Sonnenaufgang in {hour} std. {minute:02d} min.", - "sunset_at": "Sonnenuntergang in {hour} std. {minute:02d} min.", - "daylight": "Tageslicht: {hour} Std. {minute:02d} min." - }, "el":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, - "en": { - "feels_like": "Feels Like:", - "wind":"Wind:", - "humidity":"Humidity:", - "umbrella":"Grab your umbrella!", - "no_umbrella":"No umbrella needed today.", - "today":"today", - "powered_by":"A weather rock powered by Dark Sky", - "tonight":"tonight", - "tomorrow":"tomorrow", - "check_at":"Weather checked at", - "sunrise":"Sunrise: {sunrise}", - "sunset":"Sunset: {sunset}", - "sunrise_at":"Sunrise in {hour} hrs {minute:02d} min", - "sunset_at":"Sunset in {hour} hrs {minute:02d} min", - "daylight":"Daylight: {hour} hrs {minute:02d} min" - }, "eo":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, - "es": { - "feels_like": "Sensación térmica:", - "wind":"Viento:", - "humidity":"Humedad:", - "umbrella":"¡Coge el paragüas!", - "no_umbrella":"Hoy no cojas el paragüas", - "today":"hoy", - "powered_by":"Weather rock gracias a Dark Sky", - "tonight":"esta noche", - "tomorrow":"mañana", - "check_at":"Parte meteorológico de las", - "sunrise":"Amanecer: {sunrise}", - "sunset":"Puesta de sol: {sunset}", - "sunrise_at":"Amanece en {hour} hrs {minute:02d} min", - "sunset_at":"Ocaso en {hour} hrs {minute:02d} min", - "daylight":"Luz de día: {hour} hrs {minute:02d} min" - }, "et":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, - "eu":{ - "feels_like": "Sentitzen da:", - "wind": "Haizea:", - "humidity": "Hezetasuna:", - "umbrella": "Hartu aterkia!", - "no_umbrella": "Gaur ez hartu aterkia", - "today": "gaur", - "powered_by": "Weather rock Dark Sky-ri esker", - "tonight": "gaur gauean", - "tomorrow": "bihar", - "check_at": "Eguraldiaren iragarpena", - "sunrise": "Egunsentia: {sunrise}", - "sunset": "Ilunabarra: {sunset}", - "sunrise_at": "Egunsentia {hour} hrs {minute:02d} min", - "sunset_at": "Ilunabarra {hour} hrs {minute:02d} min", - "daylight": "Eguneko argia: {hour} hrs {minute:02d} min" - }, "fi":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, - "fr":{ - "feels_like": "Refroidissement éolien:", - "wind": "Vent:", - "humidity": "Humidité:", - "umbrella": "Attrape le parapluie!", - "no_umbrella": "Ne prenez pas le parapluie aujourd'hui", - "today": "aujourd'hui", - "powered_by": "Weather rock grâce à Dark Sky", - "tonight":"ce soir", - "tomorrow": "demain", - "check_at": "Bulletin météo du", - "sunrise": "Lever de soleil: {sunrise}", - "sunset": "Coucher de soleil: {sunset}", - "sunrise_at": "Lever de soleil dans {hour} hrs {minute:02d} min", - "sunset_at": "Coucher de soleil dans {hour} hrs {minute:02d} min", - "daylight": "Lumière du joir: {hour} hrs {minute:02d} min" - }, - "gl":{ - "feels_like": "Refrixeración do vento:", - "wind": "Vento:", - "moist": "Humidade:", - "umbrella": "Agarra o paraugas!", - "no_umbrella": "Non collas o paraugas hoxe", - "today": "hoxe", - "powered_by": "O tempo é rockeiro grazas a Dark Sky", - "tonight": "esta noite", - "mañá": "mañá", - "check_at": "Informe meteorolóxico do", - "sunrise": "Amanecer: {sunrise}", - "sunset": "Atardecer: {sunset}", - "sunrise_at": "Amencer en {hour} hrs {minute:02d} min", - "sunset_at": "Atardecer en {hour} hrs {minute:02d} min", - "daylight": "Luz do día: {hour} hrs {minute:02d} min" - }, "he":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, "hi":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, "hr":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, "hu":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, "id":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, "is":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, - "it":{ - "feels_like": "Si sente come:", - "wind": "Vento:", - "humidity": "Umidità:", - "umbrella": "Prendi l'ombrello!", - "no_umbrella": "Non prendere l'ombrello oggi", - "today": "today", - "powered_by": "Weather rock grazie a Dark Sky", - "tonight": "stasera", - "tomorrow": "domani", - "check_at": "Bollettino meteorologico del", - "sunrise": "Alba: {sunrise}", - "sunset": "Tramonto: {sunset}", - "sunrise_at": "Alba tra {hour} ore {minute:02d} min", - "sunset_at": "Tramonto tra {hour} ore {minute:02d} min", - "daylight": "Luce del giorno: {hour} ore {minute:02d} min" - }, "ja":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, "ka":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, "kn":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, @@ -166,23 +30,6 @@ "no":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, "pa":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, "pl":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, - "pt":{ - "feels_like": "Parece:", - "wind": "Vento:", - "humidity": "Umidade:", - "umbrella": "¡Pegue o guarda-chuva!", - "no_umbrella": "Não leve o guarda-chuva hoje", - "today": "hoje", - "powered_by": "Weather rock graças ao Dark Sky", - "tonight": "esta noite", - "tomorrow": "amanhã", - "check_at": "Boletim meteorológico de", - "sunrise": "Nascer do sol: {sunrise}", - "sunset": "Pôr do sol: {sunset}", - "sunrise_at": "Nascer do sol em {hour} horas {minute:02d} min", - "sunset_at": "Pôr do sol em {hour} horas {minute:02d} min", - "daylight": "Luz do dia: {hour} horas {minute:02d} min" - }, "ro":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, "ru":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, "sk":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, diff --git a/piweatherrock/intl/data/piweatherrock.pt.json b/piweatherrock/intl/data/piweatherrock.pt.json new file mode 100644 index 0000000..0259e87 --- /dev/null +++ b/piweatherrock/intl/data/piweatherrock.pt.json @@ -0,0 +1,19 @@ +{ + "pt":{ + "feels_like": "Parece:", + "wind": "Vento:", + "humidity": "Umidade:", + "umbrella": "¡Pegue o guarda-chuva!", + "no_umbrella": "Não leve o guarda-chuva hoje", + "today": "hoje", + "powered_by": "Weather rock graças ao Dark Sky", + "tonight": "esta noite", + "tomorrow": "amanhã", + "check_at": "Boletim meteorológico de", + "sunrise": "Nascer do sol: %{sunrise}", + "sunset": "Pôr do sol: %{sunset}", + "sunrise_at": "Nascer do sol em %{hour} horas %{minute} min", + "sunset_at": "Pôr do sol em %{hour} horas %{minute} min", + "daylight": "Luz do dia: %{hour} horas %{minute} min" + } +} diff --git a/piweatherrock/plugin_info/__init__.py b/piweatherrock/plugin_info/__init__.py index baaaeab..c1d0018 100644 --- a/piweatherrock/plugin_info/__init__.py +++ b/piweatherrock/plugin_info/__init__.py @@ -119,24 +119,24 @@ def disp_info(self, weather_rock): self.xmax * 0.05, 3, text_color) self.string_print( - self.intl.get_text(self.ui_lang,"sunrise").format(sunrise=self.sunrise_string), + self.intl.get_text(self.ui_lang,"sunrise", {'sunrise':self.sunrise_string}), small_font, self.xmax * 0.05, 4, text_color) self.string_print( - self.intl.get_text(self.ui_lang,"sunset").format(sunset=self.sunset_string), + self.intl.get_text(self.ui_lang,"sunset", {'sunset':self.sunset_string}), small_font, self.xmax * 0.05, 5, text_color) - text = self.intl.get_text(self.ui_lang,"daylight").format(hour=day_hrs, minute=day_mins) + text = self.intl.get_text(self.ui_lang,"daylight",{'hour':day_hrs,'minute':day_mins}) self.string_print(text, small_font, self.xmax * 0.05, 6, text_color) # leaving row 7 blank if in_daylight: (sunset_hour, sunset_minute) = self.stot(delta_seconds_til_dark) - text = self.intl.get_text(self.ui_lang,"sunset_at").format(hour=sunset_hour, minute=sunset_minute) + text = self.intl.get_text(self.ui_lang,"sunset_at",{'hour':sunset_hour,'minute':sunset_minute}) else: (sunrise_hour, sunrise_minute) = self.stot(seconds_til_daylight) - text = self.intl.get_text(self.ui_lang,"sunrise_at").format(hour=sunrise_hour, minute=sunrise_minute) + text = self.intl.get_text(self.ui_lang,"sunrise_at",{'hour':sunrise_hour,'minute':sunrise_minute}) self.string_print(text, small_font, self.xmax * 0.05, 8, text_color) # leaving row 9 blank @@ -145,11 +145,11 @@ def disp_info(self, weather_rock): self.string_print(text, small_font, self.xmax * 0.05, 10, text_color) if self.config["12hour_disp"]: - text = " %s" % time.strftime( + text = "%s" % time.strftime( "%I:%M:%S %p %Z on %a. %d %b %Y ", time.localtime(self.last_update_check)) else: - text = " %s" % time.strftime( + text = "%s" % time.strftime( "%H:%M:%S %Z on %a. %d %b %Y ", time.localtime(self.last_update_check)) diff --git a/piweatherrock/plugin_weather_common/__init__.py b/piweatherrock/plugin_weather_common/__init__.py index bf4b9b0..28b3f1f 100644 --- a/piweatherrock/plugin_weather_common/__init__.py +++ b/piweatherrock/plugin_weather_common/__init__.py @@ -229,26 +229,29 @@ def display_conditions_line(self, label, cond, is_temp, multiplier=None): self.screen.blit( txt, (self.xmax * x_start_position, self.ymax * y_start)) + + # position the information for the second column based on the length of the labels + second_column_x_start_position = txt.get_rect().width txt = conditions_font.render(str(cond), True, text_color) - self.screen.blit(txt, (self.xmax * second_column_x_start_position, + self.screen.blit(txt, (self.xmax * x_start_position + second_column_x_start_position * 1.01, self.ymax * y_start)) if is_temp: - txt_x = txt.get_size()[0] + txt_x = txt.get_rect().width degree_font = pygame.font.SysFont( font_name, int(self.ymax * degree_symbol_height), bold=1) degree_txt = degree_font.render(UNICODE_DEGREE, True, text_color) self.screen.blit(degree_txt, ( - self.xmax * second_column_x_start_position + txt_x * 1.01, + self.xmax * x_start_position + second_column_x_start_position + txt_x * 1.2, self.ymax * (y_start + degree_symbol_y_offset))) degree_letter = conditions_font.render( self.get_temperature_letter(self.config["units"]), True, text_color) - degree_letter_x = degree_letter.get_size()[0] + degree_letter_x = degree_letter.get_rect().width self.screen.blit(degree_letter, ( - self.xmax * second_column_x_start_position + - txt_x + degree_letter_x * 1.01, + self.xmax * x_start_position + second_column_x_start_position + + txt_x + degree_letter_x, self.ymax * (y_start + degree_symbol_y_offset))) def deg_to_compass(self, degrees): diff --git a/piweatherrock/plugin_weather_daily/__init__.py b/piweatherrock/plugin_weather_daily/__init__.py index 7258b85..a5fefda 100644 --- a/piweatherrock/plugin_weather_daily/__init__.py +++ b/piweatherrock/plugin_weather_daily/__init__.py @@ -40,7 +40,7 @@ def disp_daily(self, weather_rock): # Today today = self.weather.daily[0] - today_string = self.intl.get_text(self.ui_lang,"today", True) + today_string = self.intl.get_text(self.ui_lang,"today").capitalize() multiplier = 1 self.weather_common.display_subwindow(today, today_string, multiplier) diff --git a/piweatherrock/pwr-config-upgrade b/piweatherrock/pwr-config-upgrade deleted file mode 100644 index 9114be2..0000000 --- a/piweatherrock/pwr-config-upgrade +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -# Copyright (c) 2020 Gene Liverman -# Distributed under the MIT License (https://opensource.org/licenses/MIT) - -############################################################################### -# Raspberry Pi Weather Display Config Page Plugin -# Original By: github user: metaMMA 2020-03-15 -############################################################################### - -import json -import os -import socket - -from argparse import ArgumentParser - -pi_ip = socket.gethostbyname(socket.gethostname() + ".local") - - -def migrate_to_json_config(config_location): - print(f"\nImporting current configuration settings.\n\n" - f"Go to http://{pi_ip}:8888 to view new configuration interface.\n") - - # cd to the folder where config.py resides - config_dir = os.path.dirname(os.path.abspath(config_location)) - os.chdir(config_dir) - - # import the old config - import config - - old_config = {} - old_config["ds_api_key"] = config.DS_API_KEY - old_config["update_freq"] = int(config.DS_CHECK_INTERVAL) - old_config["lat"] = float(config.LAT) - old_config["lon"] = float(config.LON) - old_config["units"] = config.UNITS - old_config["lang"] = config.LANG - old_config["fullscreen"] = config.FULLSCREEN - old_config["icon_offset"] = float(config.LARGE_ICON_OFFSET) - old_config["plugins"] = {} - old_config["plugins"]["daily"] = {} - old_config["plugins"]["hourly"] = {} - old_config["plugins"]["daily"]["enabled"] = True - old_config["plugins"]["hourly"]["enabled"] = True - if hasattr(config, "DAILY_PAUSE"): - old_config["plugins"]["daily"]["pause"] = int(config.DAILY_PAUSE) - else: - old_config["plugins"]["daily"]["pause"] = 60 - if hasattr(config, "HOURLY_PAUSE"): - old_config["plugins"]["hourly"]["pause"] = int(config.HOURLY_PAUSE) - else: - old_config["plugins"]["hourly"]["pause"] = 60 - if hasattr(config, "INFO_PAUSE"): - old_config["info_pause"] = int(config.INFO_PAUSE) - else: - old_config["info_pause"] = 300 - if hasattr(config, "INFO_DELAY"): - old_config["info_delay"] = int(config.INFO_DELAY) - else: - old_config["info_delay"] = 900 - os.remove("config.py") - - # get out of the git repo since its not used any more - script_dir = os.path.dirname(os.path.abspath(__file__)) - os.chdir(script_dir) - - return old_config - - -def main(): - parser = ArgumentParser( - """ - Creates or updates a configuration file. - """) - parser.add_argument( - '-c', '--config', required=True, - help='Path to your config.json file') - parser.add_argument( - '-o', '--oldconfig', required=False, - help='Path to your old config.py file') - parser.add_argument( - '-s', '--sample', required=True, - help=""" - Path to config.json-sample. - This file is included automatically when installing via pip. - You can locate it with the 'find' command like so: - find /usr/local -type f -name config.json-sample - """) - - args = parser.parse_args() - config_file = os.path.abspath(args.config) - sample_file = os.path.abspath(args.sample) - - if args.oldconfig is not None and os.path.exists(args.oldconfig): - old_config_file = os.path.abspath(args.oldconfig) - old_config = migrate_to_json_config(old_config_file) - - elif os.path.exists('/home/pi/config.py'): - old_config = migrate_to_json_config('/home/pi/config.py') - - elif os.path.exists(config_file): - with open(config_file, "r") as f: - old_config = json.load(f) - - elif os.path.exists(sample_file): - with open(sample_file, "r") as f: - old_config = json.load(f) - print(f"\nYou must configure PiWeatherRock.\n\n" - f"Go to http://{pi_ip}:8888 to configure.\n") - - with open(sample_file, "r") as f: - new_config = json.load(f) - - # Add any new config variables - for key in new_config.keys(): - if key not in old_config.keys(): - old_config[key] = new_config[key] - - with open(config_file, "w") as f: - json.dump(old_config, f) - - -if __name__ == '__main__': - main() diff --git a/piweatherrock/pwr-ui b/piweatherrock/pwr-ui deleted file mode 100755 index 61656c4..0000000 --- a/piweatherrock/pwr-ui +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -# Copyright (c) 2020 Gene Liverman -# Distributed under the MIT License (https://opensource.org/licenses/MIT) - -import os -from argparse import ArgumentParser -from piweatherrock.runner import Runner - - -def main(): - parser = ArgumentParser( - """Runs the PiWeatherRock UI""") - parser.add_argument( - '-c', '--config', required=True, - help='Path to your config file') - - args = parser.parse_args() - config = os.path.abspath(args.config) - - runner = Runner() - runner.main(config) - - -if __name__ == '__main__': - main() diff --git a/requirements.txt b/requirements.txt index 718fb31..8ed4a3d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,5 +4,6 @@ pyserial requests cherrypy babel +i18n piweatherrock-webconfig==1.5.0 diff --git a/screenshot.jpeg b/screenshot.jpeg deleted file mode 100644 index 082286fc86e67b800b8c55c109a5c5f508375f3b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 165073 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

>x$ZH)uYher~}s-Q0By1_u|biAUpkQzU+cf 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 Date: Sat, 17 Apr 2021 18:42:06 +0200 Subject: [PATCH 09/91] fix requirements python-i18n --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 8ed4a3d..4bd1cdf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,6 @@ pyserial requests cherrypy babel -i18n +python-i18n piweatherrock-webconfig==1.5.0 From 16020b93fdc03fc8e91b676de89db9e4b27e4fda Mon Sep 17 00:00:00 2001 From: Carlos de Huerta Date: Sat, 17 Apr 2021 18:53:14 +0200 Subject: [PATCH 10/91] typo es lang --- piweatherrock/intl/data/piweatherrock.es.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/piweatherrock/intl/data/piweatherrock.es.json b/piweatherrock/intl/data/piweatherrock.es.json index fabc92f..0eac6bd 100644 --- a/piweatherrock/intl/data/piweatherrock.es.json +++ b/piweatherrock/intl/data/piweatherrock.es.json @@ -3,8 +3,8 @@ "feels_like": "Sensación térmica:", "wind":"Viento:", "humidity":"Humedad:", - "umbrella":"¡Coge el paragüas!", - "no_umbrella":"Hoy no cojas el paragüas", + "umbrella":"¡Coge el paraguas!", + "no_umbrella":"Hoy no cojas el paraguas", "today":"hoy", "powered_by":"Weather rock gracias a Dark Sky", "tonight":"esta noche", From ae51ac2f57968cb09f21cef82af89a43ea2c3394 Mon Sep 17 00:00:00 2001 From: Carlos de Huerta Date: Sat, 17 Apr 2021 19:08:59 +0200 Subject: [PATCH 11/91] remove unnecessary imports --- piweatherrock/plugin_info/__init__.py | 3 --- piweatherrock/plugin_weather_common/__init__.py | 1 - 2 files changed, 4 deletions(-) diff --git a/piweatherrock/plugin_info/__init__.py b/piweatherrock/plugin_info/__init__.py index c1d0018..c0459c9 100644 --- a/piweatherrock/plugin_info/__init__.py +++ b/piweatherrock/plugin_info/__init__.py @@ -9,7 +9,6 @@ # local imports from piweatherrock.intl import intl -from piweatherrock.plugin_weather_common import PluginWeatherCommon class PluginInfo: @@ -34,7 +33,6 @@ def __init__(self, weather_rock): self.time_date_small_y_position = None self.sunrise_string = None self.sunset_string = None - self.weather_common = None self.intl = None self.ui_lang = None @@ -53,7 +51,6 @@ def get_rock_values(self, weather_rock): self.time_date_small_y_position = weather_rock.time_date_small_y_position self.sunrise_string = weather_rock.sunrise_string self.sunset_string = weather_rock.sunset_string - self.weather_common = PluginWeatherCommon(weather_rock) #Initialize locale resources self.intl = intl() diff --git a/piweatherrock/plugin_weather_common/__init__.py b/piweatherrock/plugin_weather_common/__init__.py index 28b3f1f..a13531f 100644 --- a/piweatherrock/plugin_weather_common/__init__.py +++ b/piweatherrock/plugin_weather_common/__init__.py @@ -5,7 +5,6 @@ import pygame import time -import json from os import path from datetime import datetime From b25595caaf8313061a82659ac03edb37abd3298a Mon Sep 17 00:00:00 2001 From: Carlos de Huerta Date: Sun, 18 Apr 2021 11:11:48 +0200 Subject: [PATCH 12/91] included timezone --- piweatherrock/intl/__init__.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/piweatherrock/intl/__init__.py b/piweatherrock/intl/__init__.py index 6948b72..992331a 100644 --- a/piweatherrock/intl/__init__.py +++ b/piweatherrock/intl/__init__.py @@ -5,10 +5,12 @@ import json import babel import i18n +from os import path from datetime import date, datetime, time from babel.dates import format_date, format_datetime, format_time -from os import path +from babel import Locale +from babel.dates import LOCALTZ, get_timezone_name, get_timezone class intl: """ @@ -20,18 +22,24 @@ def __init__(self): i18n.set('file_format', 'json') i18n.set('fallback', 'en') i18n.load_path.append(path.join(path.dirname(__file__),'data')) + self.tz = get_timezone(LOCALTZ) def get_weekday(self, ui_lang, date): - return format_date(date,"EEEE",locale='%s' % ui_lang).capitalize() + date = self.tz.fromutc(self.tz.localize(date)) + return format_date(date,"EEEE",locale=Locale.parse(ui_lang)).capitalize() def get_datetime(self, ui_lang, datetime, twelvehr): + datetime = self.tz.fromutc(self.tz.localize(datetime)) + if twelvehr is True: - return format_datetime(datetime, "EEE, MMM dd HH:mm", locale='%s' % ui_lang).title() + return format_datetime(datetime, "EEE, MMM dd HH:mm", locale=Locale.parse(ui_lang)).title() else: - return format_datetime(datetime, "EEE, MMM dd hh:mm", locale='%s' % ui_lang).title() + return format_datetime(datetime, "EEE, MMM dd hh:mm", locale=Locale.parse(ui_lang)).title() def get_ampm(self, ui_lang, datetime): - return format_datetime(datetime, "a", locale='%s' % ui_lang) + datetime = self.tz.fromutc(self.tz.localize(datetime)) + + return format_datetime(datetime, "a", locale=Locale.parse(ui_lang)) def get_text(self, ui_lang, text, params = None): i18n.set('locale', ui_lang) From bca700f6315f9b2f12858b3f5d578e63508e57bf Mon Sep 17 00:00:00 2001 From: Carlos de Huerta Date: Sun, 30 Apr 2023 13:58:17 +0200 Subject: [PATCH 13/91] changes for openmeteo vs darksky --- install.sh | 0 piweatherrock/climate/__init__.py | 9 + piweatherrock/climate/data.py | 62 ++++ piweatherrock/climate/data/example.json | 283 ++++++++++++++++++ piweatherrock/climate/forecast.py | 97 ++++++ piweatherrock/climate/openmeteo.py | 236 +++++++++++++++ .../icons/256/_nt_spritesheet.png | Bin .../icons/256/_spritesheet.png | Bin .../icons/256/chanceflurries.png | Bin .../icons/256/chancerain.png | Bin .../icons/256/chancesleet.png | Bin .../icons/256/chancesnow.png | Bin .../icons/256/chancetstorms.png | Bin .../plugin_weather_common/icons/256/clear.png | Bin .../icons/256/cloudy.png | Bin .../icons/256/flurries.png | Bin .../plugin_weather_common/icons/256/fog.png | Bin .../plugin_weather_common/icons/256/hazy.png | Bin .../icons/256/mostlycloudy.png | Bin .../icons/256/mostlysunny.png | Bin .../icons/256/nt_chanceflurries.png | Bin .../icons/256/nt_chancerain.png | Bin .../icons/256/nt_chancesleet.png | Bin .../icons/256/nt_chancesnow.png | Bin .../icons/256/nt_chancetstorms.png | Bin .../icons/256/nt_clear.png | Bin .../icons/256/nt_cloudy.png | Bin .../icons/256/nt_flurries.png | Bin .../icons/256/nt_fog.png | Bin .../icons/256/nt_hazy.png | Bin .../icons/256/nt_mostlycloudy.png | Bin .../icons/256/nt_mostlysunny.png | Bin .../icons/256/nt_partlycloudy.png | Bin .../icons/256/nt_partlysunny.png | Bin .../icons/256/nt_rain.png | Bin .../icons/256/nt_sleet.png | Bin .../icons/256/nt_snow.png | Bin .../icons/256/nt_sunny.png | Bin .../icons/256/nt_tstorms.png | Bin .../icons/256/nt_unknown.png | Bin .../icons/256/partlycloudy.png | Bin .../icons/256/partlysunny.png | Bin .../plugin_weather_common/icons/256/rain.png | Bin .../plugin_weather_common/icons/256/sleet.png | Bin .../plugin_weather_common/icons/256/snow.png | Bin .../plugin_weather_common/icons/256/sunny.png | Bin .../icons/256/tstorms.png | Bin .../icons/256/unknown.png | Bin .../icons/64/_nt_spritesheet.png | Bin .../icons/64/_spritesheet.png | Bin .../icons/64/chanceflurries.png | Bin .../icons/64/chancerain.png | Bin .../icons/64/chancesleet.png | Bin .../icons/64/chancesnow.png | Bin .../icons/64/chancetstorms.png | Bin .../plugin_weather_common/icons/64/clear.png | Bin .../plugin_weather_common/icons/64/cloudy.png | Bin .../icons/64/flurries.png | Bin .../plugin_weather_common/icons/64/fog.png | Bin .../plugin_weather_common/icons/64/hazy.png | Bin .../icons/64/mostlycloudy.png | Bin .../icons/64/mostlysunny.png | Bin .../icons/64/nt_chanceflurries.png | Bin .../icons/64/nt_chancerain.png | Bin .../icons/64/nt_chancesleet.png | Bin .../icons/64/nt_chancesnow.png | Bin .../icons/64/nt_chancetstorms.png | Bin .../icons/64/nt_clear.png | Bin .../icons/64/nt_cloudy.png | Bin .../icons/64/nt_flurries.png | Bin .../plugin_weather_common/icons/64/nt_fog.png | Bin .../icons/64/nt_hazy.png | Bin .../icons/64/nt_mostlycloudy.png | Bin .../icons/64/nt_mostlysunny.png | Bin .../icons/64/nt_partlycloudy.png | Bin .../icons/64/nt_partlysunny.png | Bin .../icons/64/nt_rain.png | Bin .../icons/64/nt_sleet.png | Bin .../icons/64/nt_snow.png | Bin .../icons/64/nt_sunny.png | Bin .../icons/64/nt_tstorms.png | Bin .../icons/64/nt_unknown.png | Bin .../icons/64/partlycloudy.png | Bin .../icons/64/partlysunny.png | Bin .../plugin_weather_common/icons/64/rain.png | Bin .../plugin_weather_common/icons/64/sleet.png | Bin .../plugin_weather_common/icons/64/snow.png | Bin .../plugin_weather_common/icons/64/sunny.png | Bin .../icons/64/tstorms.png | Bin .../icons/64/unknown.png | Bin .../icons/alt_icons/generate-dark-sky-pngs.sh | 0 piweatherrock/runner.py | 1 + piweatherrock/weather.py | 12 +- requirements.txt | 2 +- scripts/pwr-ui | 0 95 files changed, 696 insertions(+), 6 deletions(-) mode change 100755 => 100644 install.sh create mode 100644 piweatherrock/climate/__init__.py create mode 100644 piweatherrock/climate/data.py create mode 100644 piweatherrock/climate/data/example.json create mode 100644 piweatherrock/climate/forecast.py create mode 100644 piweatherrock/climate/openmeteo.py mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/256/_nt_spritesheet.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/256/_spritesheet.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/256/chanceflurries.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/256/chancerain.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/256/chancesleet.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/256/chancesnow.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/256/chancetstorms.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/256/clear.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/256/cloudy.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/256/flurries.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/256/fog.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/256/hazy.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/256/mostlycloudy.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/256/mostlysunny.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/256/nt_chanceflurries.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/256/nt_chancerain.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/256/nt_chancesleet.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/256/nt_chancesnow.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/256/nt_chancetstorms.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/256/nt_clear.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/256/nt_cloudy.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/256/nt_flurries.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/256/nt_fog.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/256/nt_hazy.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/256/nt_mostlycloudy.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/256/nt_mostlysunny.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/256/nt_partlycloudy.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/256/nt_partlysunny.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/256/nt_rain.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/256/nt_sleet.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/256/nt_snow.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/256/nt_sunny.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/256/nt_tstorms.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/256/nt_unknown.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/256/partlycloudy.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/256/partlysunny.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/256/rain.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/256/sleet.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/256/snow.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/256/sunny.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/256/tstorms.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/256/unknown.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/64/_nt_spritesheet.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/64/_spritesheet.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/64/chanceflurries.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/64/chancerain.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/64/chancesleet.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/64/chancesnow.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/64/chancetstorms.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/64/clear.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/64/cloudy.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/64/flurries.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/64/fog.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/64/hazy.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/64/mostlycloudy.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/64/mostlysunny.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/64/nt_chanceflurries.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/64/nt_chancerain.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/64/nt_chancesleet.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/64/nt_chancesnow.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/64/nt_chancetstorms.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/64/nt_clear.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/64/nt_cloudy.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/64/nt_flurries.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/64/nt_fog.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/64/nt_hazy.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/64/nt_mostlycloudy.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/64/nt_mostlysunny.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/64/nt_partlycloudy.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/64/nt_partlysunny.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/64/nt_rain.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/64/nt_sleet.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/64/nt_snow.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/64/nt_sunny.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/64/nt_tstorms.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/64/nt_unknown.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/64/partlycloudy.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/64/partlysunny.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/64/rain.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/64/sleet.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/64/snow.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/64/sunny.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/64/tstorms.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/64/unknown.png mode change 100755 => 100644 piweatherrock/plugin_weather_common/icons/alt_icons/generate-dark-sky-pngs.sh mode change 100755 => 100644 scripts/pwr-ui diff --git a/install.sh b/install.sh old mode 100755 new mode 100644 diff --git a/piweatherrock/climate/__init__.py b/piweatherrock/climate/__init__.py new file mode 100644 index 0000000..318fc8d --- /dev/null +++ b/piweatherrock/climate/__init__.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2023 Carlos de Huerta +# Distributed under the MIT License (https://opensource.org/licenses/MIT) + +from .forecast import Forecast + + +def forecast(key, latitude, longitude, time=None, timeout=None, **queries): + return Forecast(key, latitude, longitude, time, timeout, **queries) diff --git a/piweatherrock/climate/data.py b/piweatherrock/climate/data.py new file mode 100644 index 0000000..1e26725 --- /dev/null +++ b/piweatherrock/climate/data.py @@ -0,0 +1,62 @@ +# data.py + + +class DataPoint(object): + def __init__(self, data): + self._data = data + + if isinstance(self._data, dict): + for name, val in self._data.items(): + setattr(self, name, val) + + if isinstance(self._data, list): + setattr(self, 'data', self._data) + + def __setattr__(self, name, val): + def setval(new_val=None): + return object.__setattr__(self, name, new_val if new_val else val) + + # regular value + if not isinstance(val, (list, dict)) or name == '_data': + return setval() + + # set specific data handlers + if name in ('alerts', 'flags'): + return setval(eval(name.capitalize())(val)) + + # data + if isinstance(val, list): + val = [DataPoint(v) if isinstance(v, dict) else v for v in val] + return setval(val) + + # set general data handlers + setval(DataBlock(val) if 'data' in val.keys() else DataPoint(val)) + + def __getitem__(self, key): + return self._data[key] + + def __len__(self): + return len(self._data) + + +class DataBlock(DataPoint): + def __iter__(self): + return self.data.__iter__() + + def __getitem__(self, index): + # keys in darksky API datablocks are always str + if isinstance(index, str): + return self._data[index] + return self.data.__getitem__(index) + + def __len__(self): + return self.data.__len__() + + +class Flags(DataPoint): + def __setattr__(self, name, value): + return object.__setattr__(self, name.replace('-', '_'), value) + + +class Alerts(DataBlock): + pass diff --git a/piweatherrock/climate/data/example.json b/piweatherrock/climate/data/example.json new file mode 100644 index 0000000..47d4a1a --- /dev/null +++ b/piweatherrock/climate/data/example.json @@ -0,0 +1,283 @@ +{ + "latitude": 40.3, + "longitude": -3.7399998, + "timezone": "Europe/Madrid", + "currently": { + "time": 1682848800, + "summary": "Nublado", + "icon": "cloudy", + "nearestStormDistance": 0, + "precipIntensity": 3, + "precipIntensityError": 0, + "precipProbability": 3, + "precipType": "rain", + "temperature": 17.0, + "apparentTemperature": 17.0, + "dewPoint": 0, + "humidity": 0, + "pressure": 0, + "windSpeed": 9.0, + "windGust": 46.1, + "windBearing": 61.0, + "cloudCover": 0, + "uvIndex": 6.2, + "visibility": 0, + "ozone": 0 + }, + "daily": { + "summary": null, + "icon": null, + "data": [ + { + "time": 1682812800, + "summary": "Nublado", + "icon": "cloudy", + "sunriseTime": 1682838840, + "sunsetTime": 1682889000, + "temperatureHigh": 25.0, + "temperatureLow": 13.7, + "moonPhase": 0, + "precipIntensity": 3, + "precipIntensityMax": 3, + "precipIntensityMaxTime": 0, + "precipProbability": 3, + "precipType": "rain", + "temperatureHighTime": 0, + "temperatureLowTime": 0, + "apparentTemperatureHigh": 23.5, + "apparentTemperatureHighTime": 0, + "apparentTemperatureLow": 12.0, + "apparentTemperatureLowTime": 0, + "dewPoint": 0, + "humidity": 0, + "pressure": 0, + "windSpeed": 12.2, + "windGust": 46.1, + "windGustTime": 0, + "windBearing": 38, + "cloudCover": 0, + "uvIndex": 6.2, + "uvIndexTime": 0, + "visibility": 0, + "ozone": 0, + "temperatureMin": 13.7, + "temperatureMinTime": 0, + "temperatureMax": 25.0, + "temperatureMaxTime": 0, + "apparentTemperatureMin": 12.0, + "apparentTemperatureMinTime": 0, + "apparentTemperatureMax": 23.5, + "apparentTemperatureMaxTime": 0 + }, + { + "time": 1682899200, + "summary": "Nublado", + "icon": "cloudy", + "sunriseTime": 1682925180, + "sunsetTime": 1682975460, + "temperatureHigh": 26.5, + "temperatureLow": 13.5, + "moonPhase": 0, + "precipIntensity": 3, + "precipIntensityMax": 3, + "precipIntensityMaxTime": 0, + "precipProbability": 3, + "precipType": "rain", + "temperatureHighTime": 0, + "temperatureLowTime": 0, + "apparentTemperatureHigh": 24.1, + "apparentTemperatureHighTime": 0, + "apparentTemperatureLow": 10.7, + "apparentTemperatureLowTime": 0, + "dewPoint": 0, + "humidity": 0, + "pressure": 0, + "windSpeed": 18.3, + "windGust": 35.6, + "windGustTime": 0, + "windBearing": 34, + "cloudCover": 0, + "uvIndex": 7.55, + "uvIndexTime": 0, + "visibility": 0, + "ozone": 0, + "temperatureMin": 13.5, + "temperatureMinTime": 0, + "temperatureMax": 26.5, + "temperatureMaxTime": 0, + "apparentTemperatureMin": 10.7, + "apparentTemperatureMinTime": 0, + "apparentTemperatureMax": 24.1, + "apparentTemperatureMaxTime": 0 + }, + { + "time": 1682985600, + "summary": "Nublado", + "icon": "cloudy", + "sunriseTime": 1683011460, + "sunsetTime": 1683061920, + "temperatureHigh": 28.6, + "temperatureLow": 16.0, + "moonPhase": 0, + "precipIntensity": 0, + "precipIntensityMax": 0, + "precipIntensityMaxTime": 0, + "precipProbability": 0, + "precipType": "rain", + "temperatureHighTime": 0, + "temperatureLowTime": 0, + "apparentTemperatureHigh": 28.8, + "apparentTemperatureHighTime": 0, + "apparentTemperatureLow": 11.8, + "apparentTemperatureLowTime": 0, + "dewPoint": 0, + "humidity": 0, + "pressure": 0, + "windSpeed": 15.4, + "windGust": 25.9, + "windGustTime": 0, + "windBearing": 35, + "cloudCover": 0, + "uvIndex": 7.65, + "uvIndexTime": 0, + "visibility": 0, + "ozone": 0, + "temperatureMin": 16.0, + "temperatureMinTime": 0, + "temperatureMax": 28.6, + "temperatureMaxTime": 0, + "apparentTemperatureMin": 11.8, + "apparentTemperatureMinTime": 0, + "apparentTemperatureMax": 28.8, + "apparentTemperatureMaxTime": 0 + }, + { + "time": 1683072000, + "summary": "Nublado", + "icon": "cloudy", + "sunriseTime": 1683097800, + "sunsetTime": 1683148380, + "temperatureHigh": 31.3, + "temperatureLow": 18.2, + "moonPhase": 0, + "precipIntensity": 0, + "precipIntensityMax": 0, + "precipIntensityMaxTime": 0, + "precipProbability": 0, + "precipType": "rain", + "temperatureHighTime": 0, + "temperatureLowTime": 0, + "apparentTemperatureHigh": 29.8, + "apparentTemperatureHighTime": 0, + "apparentTemperatureLow": 15.2, + "apparentTemperatureLowTime": 0, + "dewPoint": 0, + "humidity": 0, + "pressure": 0, + "windSpeed": 40.1, + "windGust": 64.1, + "windGustTime": 0, + "windBearing": 263, + "cloudCover": 0, + "uvIndex": 7.7, + "uvIndexTime": 0, + "visibility": 0, + "ozone": 0, + "temperatureMin": 18.2, + "temperatureMinTime": 0, + "temperatureMax": 31.3, + "temperatureMaxTime": 0, + "apparentTemperatureMin": 15.2, + "apparentTemperatureMinTime": 0, + "apparentTemperatureMax": 29.8, + "apparentTemperatureMaxTime": 0 + } + ] + }, + "hourly": { + "summary": null, + "icon": null, + "data": [ + { + "time": 1682852400, + "summary": "Nublado", + "icon": "cloudy", + "precipIntensity": 0, + "precipProbability": 0, + "precipType": "rain", + "temperature": 18.6, + "apparentTemperature": 16.3, + "dewPoint": 8.2, + "humidity": 51, + "pressure": 945.1, + "windSpeed": 12.2, + "windGust": 24.8, + "windBearing": 62, + "cloudCover": 0, + "uvIndex": 456.3, + "visibility": 24140.0, + "ozone": 0 + }, + { + "time": 1682856000, + "summary": "Nublado", + "icon": "cloudy", + "precipIntensity": 0, + "precipProbability": 0, + "precipType": "rain", + "temperature": 20.3, + "apparentTemperature": 18.6, + "dewPoint": 8.3, + "humidity": 46, + "pressure": 945.4, + "windSpeed": 8.2, + "windGust": 25.2, + "windBearing": 61, + "cloudCover": 0, + "uvIndex": 640.9, + "visibility": 24140.0, + "ozone": 0 + }, + { + "time": 1682859600, + "summary": "Nublado", + "icon": "cloudy", + "precipIntensity": 0, + "precipProbability": 0, + "precipType": "rain", + "temperature": 21.5, + "apparentTemperature": 20.4, + "dewPoint": 8.1, + "humidity": 42, + "pressure": 945.0, + "windSpeed": 4.2, + "windGust": 21.2, + "windBearing": 70, + "cloudCover": 0, + "uvIndex": 670.2, + "visibility": 24140.0, + "ozone": 0 + }, + { + "time": 1682863200, + "summary": "Nublado", + "icon": "cloudy", + "precipIntensity": 0, + "precipProbability": 0, + "precipType": "rain", + "temperature": 22.7, + "apparentTemperature": 21.2, + "dewPoint": 7.7, + "humidity": 38, + "pressure": 944.4, + "windSpeed": 6.4, + "windGust": 22.3, + "windBearing": 43, + "cloudCover": 0, + "uvIndex": 684.2, + "visibility": 24140.0, + "ozone": 0 + } + ] + } +} \ No newline at end of file diff --git a/piweatherrock/climate/forecast.py b/piweatherrock/climate/forecast.py new file mode 100644 index 0000000..d0bed03 --- /dev/null +++ b/piweatherrock/climate/forecast.py @@ -0,0 +1,97 @@ +# forecast.py +from __future__ import print_function +from builtins import super + +import json +import sys +import requests +from os import path + +from .data import DataPoint +from .openmeteo import * + +# format from: +# https://open-meteo.com/en/docs#latitude=40.31&longitude=-3.73&hourly=temperature_2m +# info for mapping: https://openweathermap.org/darksky-openweather-3 +_API_URL = "https://api.open-meteo.com/v1/forecast" +_LOAD_FROM_FILE_ = False # Set this to True to load JSON from a file, or False to make an HTTP GET request + +class Forecast(DataPoint): + def __init__(self, key, latitude, longitude, time=None, timeout=None, **queries): + self._parameters = dict(key=key, latitude=latitude, longitude=longitude, time=time) + self.refresh(timeout, **queries) + + def __setattr__(self, key, value): + if key in ('_queries', '_parameters', '_data'): + return object.__setattr__(self, key, value) + return super().__setattr__(key, value) + + def __getattr__(self, key): + if key in self.currently._data.keys(): + return self.currently._data[key] + return object.__getattribute__(self, key) + + def __enter__(self): + return self + + def __exit__(self, type, value, tb): + del self + + def load_json_file(self, file_path): + with open(file_path, 'r') as file: + data = json.load(file) + return data + + @property + def url(self): + time = self._parameters['time'] + timestr = ',{}'.format(time) if time else '' + config = { + "forecast_days": 4, + "models": "best_match", + "current_weather": "true", + "temperature_unit": "celsius", + "windspeed_unit": "kmh", + "precipitation_unit": "mm", + "timeformat": "iso8601", + "hourly":"visibility,weathercode,temperature_2m,relativehumidity_2m,apparent_temperature,surface_pressure,cloudcover,windspeed_80m,precipitation,precipitation_probability,dewpoint_2m,windspeed_10m,windgusts_10m,winddirection_10m,cloudcover_low,direct_radiation", + "daily":"sunrise,sunset,uv_index_max,weathercode,temperature_2m_max,temperature_2m_min,apparent_temperature_max,apparent_temperature_min,precipitation_sum,precipitation_probability_mean,precipitation_probability_min,windgusts_10m_max,precipitation_probability_max,windspeed_10m_max,winddirection_10m_dominant" + } + + uri_format = '{url}?latitude={latitude}&longitude={longitude}&appid={key}&timezone={timezone}&models={models}&forecast_days={forecast_days}¤t_weather={current_weather}&temperature_unit={temperature_unit}&windspeed_unit={windspeed_unit}&precipitation_unit={precipitation_unit}&timeformat={timeformat}&hourly={hourly}&daily={daily}' + return uri_format.format( + url=_API_URL, + timestr=timestr, + timezone = self._queries["timezone"], + forecast_days = config["forecast_days"], + current_weather = config["current_weather"], + models = config["models"], + temperature_unit = config["temperature_unit"], + windspeed_unit = config["windspeed_unit"], + precipitation_unit = config["precipitation_unit"], + timeformat = config["timeformat"], + hourly = config["hourly"], + daily = config["daily"], + **self._parameters) + + def refresh(self, timeout=None, **queries): + self._queries = queries + self.timeout = timeout + request_params = { + 'params': self._queries, + 'headers': {'Accept-Encoding': 'gzip'}, + 'timeout': timeout + } + + if _LOAD_FROM_FILE_: + file_path = path.join(path.dirname(__file__),'data','example.json') + data = self.load_json_file(file_path) + + return super().__init__(data) + else: + response = requests.get(self.url, **request_params) + self.response_headers = response.headers + if response.status_code != 200: + raise requests.exceptions.HTTPError('Bad response') + + return super().__init__(openmeteo_to_darksky(response.text, queries["lang"])) \ No newline at end of file diff --git a/piweatherrock/climate/openmeteo.py b/piweatherrock/climate/openmeteo.py new file mode 100644 index 0000000..7c6d5ad --- /dev/null +++ b/piweatherrock/climate/openmeteo.py @@ -0,0 +1,236 @@ +# openmeteo.py + +import json +import datetime +import time +from pytz import timezone + +def get_weather_translations(lang, wmocode): + weather_translations = { + 0: {"en": "Clear sky", "es": "Cielo despejado"}, + 1: {"en": "Mainly clear", "es": "Mayormente despejado"}, + 2: {"en": "Partly cloudy", "es": "Parcialmente nublado"}, + 3: {"en": "Overcast", "es": "Nublado"}, + 45: {"en": "Fog", "es": "Niebla"}, + 48: {"en": "Depositing rime fog", "es": "Niebla depositada"}, + 51: {"en": "Drizzle: Light intensity", "es": "Llovizna: Intensidad ligera"}, + 53: {"en": "Drizzle: Moderate intensity", "es": "Llovizna: Intensidad moderada"}, + 55: {"en": "Drizzle: Dense intensity", "es": "Llovizna: Intensidad densa"}, + 56: {"en": "Freezing Drizzle: Light intensity", "es": "Llovizna helada: Intensidad ligera"}, + 57: {"en": "Freezing Drizzle: Dense intensity", "es": "Llovizna helada: Intensidad densa"}, + 61: {"en": "Rain: Slight intensity", "es": "Lluvia: Intensidad ligera"}, + 63: {"en": "Rain: Moderate intensity", "es": "Lluvia: Intensidad moderada"}, + 65: {"en": "Rain: Heavy intensity", "es": "Lluvia: Intensidad fuerte"}, + 66: {"en": "Freezing Rain: Light intensity", "es": "Lluvia helada: Intensidad ligera"}, + 67: {"en": "Freezing Rain: Heavy intensity", "es": "Lluvia helada: Intensidad fuerte"}, + 71: {"en": "Snow fall: Slight intensity", "es": "Nevada: Intensidad ligera"}, + 73: {"en": "Snow fall: Moderate intensity", "es": "Nevada: Intensidad moderada"}, + 75: {"en": "Snow fall: Heavy intensity", "es": "Nevada: Intensidad fuerte"}, + 77: {"en": "Snow grains", "es": "Granos de nieve"}, + 80: {"en": "Rain showers: Slight intensity", "es": "Lluvias: Intensidad ligera"}, + 81: {"en": "Rain showers: Moderate intensity", "es": "Lluvias: Intensidad moderada"}, + 82: {"en": "Rain showers: Violent intensity", "es": "Lluvias: Intensidad fuerte"}, + 85: {"en": "Snow showers: Slight intensity", "es": "Nevadas: Intensidad ligera"}, + 86: {"en": "Snow showers: Heavy intensity", "es": "Nevadas: Intensidad fuerte"}, + 95: {"en": "Thunderstorm: Slight or moderate", "es": "Tormenta eléctrica: Ligera o moderada"}, + 96: {"en": "Thunderstorm with slight hail", "es": "Tormenta eléctrica con granizo ligero"}, + 99: {"en": "Thunderstorm with heavy hail", "es": "Tormenta eléctrica con granizo fuerte"} + } + return weather_translations.get(wmocode, {}).get(lang, "Unknown" if lang == "en" else "Desconocido") + +def get_darksky_icon(wmocode): + icon_map = { + 0: 'clear', + 1: 'mostlysunny', + 2: 'partlycloudy', + 3: 'cloudy', + 45: 'fog', + 48: 'hazy', + 51: 'chancerain', + 53: 'rain', + 55: 'rain', + 56: 'chainsleet', + 57: 'sleet', + 61: 'chancerain', + 63: 'rain', + 65: 'rain', + 66: 'chainsleet', + 67: 'sleet', + 71: 'chancesnow', + 73: 'chancesnow', + 75: 'snow', + 77: 'snow', + 80: 'rain', + 81: 'rain', + 82: 'rain', + 85: 'chanceflurries', + 86: 'flurries', + 95: 'tstorm', + 96: 'chancetstorms', + 99: 'tstorms' + } + return icon_map.get(wmocode, 'unknown') + +def openmeteo_to_darksky(data, lang): + darksky_data = {} + json_data = json.loads(data) + + # Latitude, Longitude and Timezone + darksky_data["latitude"] = json_data["latitude"] + darksky_data["longitude"] = json_data["longitude"] + darksky_data["timezone"] = json_data["timezone"] + + # Current weather data + current_date_obj = datetime.datetime.fromisoformat(json_data["current_weather"]["time"]) + current_unix_timestamp = int(time.mktime(current_date_obj.timetuple())) + + # Get the first day for the current weather, and set the variable dor daily and hourly + daily_data = json_data["daily"] + hourly_data = json_data["hourly"] + + # Hourly weather data + darksky_data["hourly"] = { + "summary": "", + "icon": "", + "data": [] + } + + # Filter time array to get only the 4 next records based on current time + time_zone_str = json_data["timezone"] + tz = timezone(time_zone_str) + current_datetime = datetime.datetime.now(tz) + upper_limit = current_datetime + datetime.timedelta(hours=4) + + filtered_hourly_data = {} + indexes = [] + + for key in hourly_data.keys(): + if key == 'time': + filtered_hourly_data[key] = [] + for i, date_value in enumerate(hourly_data[key]): + date_obj = tz.localize(datetime.datetime.fromisoformat(date_value)) + if current_datetime <= date_obj < upper_limit: + filtered_hourly_data[key].append(hourly_data[key][i]) + indexes.append(i) + + for key in hourly_data.keys(): + if key != 'time': + filtered_hourly_data[key] = [] + for i in indexes: + filtered_hourly_data[key].append(hourly_data[key][i]) + + filtered_num_hours = len(filtered_hourly_data["time"]) + for i in range(filtered_num_hours): + time_date_obj = datetime.datetime.fromisoformat(filtered_hourly_data["time"][i]) + time_unix_timestamp = int(time.mktime(time_date_obj.timetuple())) + + darksky_hour_data = { + "time": time_unix_timestamp, + "summary": get_weather_translations(lang, filtered_hourly_data["weathercode"][i]), + "icon": get_darksky_icon(filtered_hourly_data["weathercode"][i]), + "precipIntensity": filtered_hourly_data["precipitation_probability"][i], + "precipProbability": filtered_hourly_data["precipitation_probability"][i] / 100, + "precipType": "rain", + "temperature": filtered_hourly_data["temperature_2m"][i], + "apparentTemperature": filtered_hourly_data["apparent_temperature"][i], + "dewPoint": filtered_hourly_data["dewpoint_2m"][i], + "humidity": filtered_hourly_data["relativehumidity_2m"][i] / 100, + "pressure": filtered_hourly_data["surface_pressure"][i], + "windSpeed": filtered_hourly_data["windspeed_10m"][i], + "windGust": filtered_hourly_data["windgusts_10m"][i], + "windBearing": filtered_hourly_data["winddirection_10m"][i], + "cloudCover": filtered_hourly_data["cloudcover_low"][i], + "uvIndex": filtered_hourly_data["direct_radiation"][i], + "visibility": filtered_hourly_data["visibility"][i], + "ozone": 0, + } + darksky_data["hourly"]["data"].append(darksky_hour_data) + darksky_data["hourly"]["summary"] = get_weather_translations(lang, filtered_hourly_data["weathercode"][0]) + darksky_data["hourly"]["icon"] = get_darksky_icon(filtered_hourly_data["weathercode"][0]) + + # Daily weather data + darksky_data["daily"] = { + "summary": get_weather_translations(lang, daily_data["weathercode"][0]), + "icon": get_darksky_icon(daily_data["weathercode"][0]), + "data": [] + } + + num_days = len(daily_data['time']) + + for i in range(num_days): + time_date_obj = datetime.datetime.fromisoformat(daily_data["time"][i]) + time_unix_timestamp = int(time.mktime(time_date_obj.timetuple())) + + sunset_date_obj = datetime.datetime.fromisoformat(daily_data["sunset"][i]) + sunset_unix_timestamp = int(time.mktime(sunset_date_obj.timetuple())) + + sunrise_date_obj = datetime.datetime.fromisoformat(daily_data["sunrise"][i]) + sunrise_unix_timestamp = int(time.mktime(sunrise_date_obj.timetuple())) + + darksky_day_data = { + "time": time_unix_timestamp, + "summary": get_weather_translations(lang, daily_data["weathercode"][i]), + "icon": get_darksky_icon(daily_data["weathercode"][i]), + "sunriseTime": sunrise_unix_timestamp, + "sunsetTime": sunset_unix_timestamp, + "temperatureHigh": daily_data["temperature_2m_max"][i], + "temperatureLow": daily_data["temperature_2m_min"][i], + "moonPhase": 0, + "precipIntensity": daily_data["precipitation_probability_min"][i], + "precipIntensityMax": daily_data["precipitation_probability_max"][i], + "precipIntensityMaxTime": 0, + "precipProbability": daily_data["precipitation_probability_mean"][i] / 100, + "precipType": "rain", + "temperatureHighTime": 0, + "temperatureLowTime": 0, + "apparentTemperatureHigh": daily_data["apparent_temperature_max"][i], + "apparentTemperatureHighTime": 0, + "apparentTemperatureLow": daily_data["apparent_temperature_min"][i], + "apparentTemperatureLowTime": 0, + "dewPoint": filtered_hourly_data["dewpoint_2m"][0], + "humidity": filtered_hourly_data["relativehumidity_2m"][0] / 100, + "pressure": filtered_hourly_data["surface_pressure"][0], + "windSpeed": daily_data["windspeed_10m_max"][i], + "windGust": daily_data["windgusts_10m_max"][i], + "windGustTime": 0, + "windBearing": daily_data["winddirection_10m_dominant"][i], + "cloudCover": filtered_hourly_data["cloudcover_low"][0], + "uvIndex": daily_data["uv_index_max"][i], + "uvIndexTime": 0, + "visibility": filtered_hourly_data["visibility"][0], + "ozone": 0, + "temperatureMin": daily_data["temperature_2m_min"][i], + "temperatureMinTime": 0, + "temperatureMax": daily_data["temperature_2m_max"][i], + "temperatureMaxTime": 0, + "apparentTemperatureMin": daily_data["apparent_temperature_min"][i], + "apparentTemperatureMinTime": 0, + "apparentTemperatureMax": daily_data["apparent_temperature_max"][i], + "apparentTemperatureMaxTime": 0, + } + darksky_data["daily"]["data"].append(darksky_day_data) + + darksky_data["currently"] = { + "time": current_unix_timestamp, + "summary": get_weather_translations(lang, daily_data["weathercode"][0]), + "icon": get_darksky_icon(daily_data["weathercode"][0]), + "nearestStormDistance": 0, + "precipIntensity": daily_data["precipitation_probability_min"][0], + "precipIntensityError": 0, + "precipProbability": daily_data["precipitation_probability_mean"][0] /, + "precipType": "rain", + "temperature": json_data["current_weather"]["temperature"], + "apparentTemperature": json_data["current_weather"]["temperature"], + "dewPoint": filtered_hourly_data["dewpoint_2m"][0], + "humidity": filtered_hourly_data["relativehumidity_2m"][0] / 100, + "pressure": filtered_hourly_data["surface_pressure"][0], + "windSpeed": json_data["current_weather"]["windspeed"], + "windGust": daily_data["windgusts_10m_max"][0], + "windBearing": json_data["current_weather"]["winddirection"], + "cloudCover": filtered_hourly_data["cloudcover_low"][0], + "uvIndex": daily_data["uv_index_max"][0], + "visibility": filtered_hourly_data["visibility"][0], + "ozone": 0 + } + + return darksky_data \ No newline at end of file diff --git a/piweatherrock/plugin_weather_common/icons/256/_nt_spritesheet.png b/piweatherrock/plugin_weather_common/icons/256/_nt_spritesheet.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/256/_spritesheet.png b/piweatherrock/plugin_weather_common/icons/256/_spritesheet.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/256/chanceflurries.png b/piweatherrock/plugin_weather_common/icons/256/chanceflurries.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/256/chancerain.png b/piweatherrock/plugin_weather_common/icons/256/chancerain.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/256/chancesleet.png b/piweatherrock/plugin_weather_common/icons/256/chancesleet.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/256/chancesnow.png b/piweatherrock/plugin_weather_common/icons/256/chancesnow.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/256/chancetstorms.png b/piweatherrock/plugin_weather_common/icons/256/chancetstorms.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/256/clear.png b/piweatherrock/plugin_weather_common/icons/256/clear.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/256/cloudy.png b/piweatherrock/plugin_weather_common/icons/256/cloudy.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/256/flurries.png b/piweatherrock/plugin_weather_common/icons/256/flurries.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/256/fog.png b/piweatherrock/plugin_weather_common/icons/256/fog.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/256/hazy.png b/piweatherrock/plugin_weather_common/icons/256/hazy.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/256/mostlycloudy.png b/piweatherrock/plugin_weather_common/icons/256/mostlycloudy.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/256/mostlysunny.png b/piweatherrock/plugin_weather_common/icons/256/mostlysunny.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/256/nt_chanceflurries.png b/piweatherrock/plugin_weather_common/icons/256/nt_chanceflurries.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/256/nt_chancerain.png b/piweatherrock/plugin_weather_common/icons/256/nt_chancerain.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/256/nt_chancesleet.png b/piweatherrock/plugin_weather_common/icons/256/nt_chancesleet.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/256/nt_chancesnow.png b/piweatherrock/plugin_weather_common/icons/256/nt_chancesnow.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/256/nt_chancetstorms.png b/piweatherrock/plugin_weather_common/icons/256/nt_chancetstorms.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/256/nt_clear.png b/piweatherrock/plugin_weather_common/icons/256/nt_clear.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/256/nt_cloudy.png b/piweatherrock/plugin_weather_common/icons/256/nt_cloudy.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/256/nt_flurries.png b/piweatherrock/plugin_weather_common/icons/256/nt_flurries.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/256/nt_fog.png b/piweatherrock/plugin_weather_common/icons/256/nt_fog.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/256/nt_hazy.png b/piweatherrock/plugin_weather_common/icons/256/nt_hazy.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/256/nt_mostlycloudy.png b/piweatherrock/plugin_weather_common/icons/256/nt_mostlycloudy.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/256/nt_mostlysunny.png b/piweatherrock/plugin_weather_common/icons/256/nt_mostlysunny.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/256/nt_partlycloudy.png b/piweatherrock/plugin_weather_common/icons/256/nt_partlycloudy.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/256/nt_partlysunny.png b/piweatherrock/plugin_weather_common/icons/256/nt_partlysunny.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/256/nt_rain.png b/piweatherrock/plugin_weather_common/icons/256/nt_rain.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/256/nt_sleet.png b/piweatherrock/plugin_weather_common/icons/256/nt_sleet.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/256/nt_snow.png b/piweatherrock/plugin_weather_common/icons/256/nt_snow.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/256/nt_sunny.png b/piweatherrock/plugin_weather_common/icons/256/nt_sunny.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/256/nt_tstorms.png b/piweatherrock/plugin_weather_common/icons/256/nt_tstorms.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/256/nt_unknown.png b/piweatherrock/plugin_weather_common/icons/256/nt_unknown.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/256/partlycloudy.png b/piweatherrock/plugin_weather_common/icons/256/partlycloudy.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/256/partlysunny.png b/piweatherrock/plugin_weather_common/icons/256/partlysunny.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/256/rain.png b/piweatherrock/plugin_weather_common/icons/256/rain.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/256/sleet.png b/piweatherrock/plugin_weather_common/icons/256/sleet.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/256/snow.png b/piweatherrock/plugin_weather_common/icons/256/snow.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/256/sunny.png b/piweatherrock/plugin_weather_common/icons/256/sunny.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/256/tstorms.png b/piweatherrock/plugin_weather_common/icons/256/tstorms.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/256/unknown.png b/piweatherrock/plugin_weather_common/icons/256/unknown.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/64/_nt_spritesheet.png b/piweatherrock/plugin_weather_common/icons/64/_nt_spritesheet.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/64/_spritesheet.png b/piweatherrock/plugin_weather_common/icons/64/_spritesheet.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/64/chanceflurries.png b/piweatherrock/plugin_weather_common/icons/64/chanceflurries.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/64/chancerain.png b/piweatherrock/plugin_weather_common/icons/64/chancerain.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/64/chancesleet.png b/piweatherrock/plugin_weather_common/icons/64/chancesleet.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/64/chancesnow.png b/piweatherrock/plugin_weather_common/icons/64/chancesnow.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/64/chancetstorms.png b/piweatherrock/plugin_weather_common/icons/64/chancetstorms.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/64/clear.png b/piweatherrock/plugin_weather_common/icons/64/clear.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/64/cloudy.png b/piweatherrock/plugin_weather_common/icons/64/cloudy.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/64/flurries.png b/piweatherrock/plugin_weather_common/icons/64/flurries.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/64/fog.png b/piweatherrock/plugin_weather_common/icons/64/fog.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/64/hazy.png b/piweatherrock/plugin_weather_common/icons/64/hazy.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/64/mostlycloudy.png b/piweatherrock/plugin_weather_common/icons/64/mostlycloudy.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/64/mostlysunny.png b/piweatherrock/plugin_weather_common/icons/64/mostlysunny.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/64/nt_chanceflurries.png b/piweatherrock/plugin_weather_common/icons/64/nt_chanceflurries.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/64/nt_chancerain.png b/piweatherrock/plugin_weather_common/icons/64/nt_chancerain.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/64/nt_chancesleet.png b/piweatherrock/plugin_weather_common/icons/64/nt_chancesleet.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/64/nt_chancesnow.png b/piweatherrock/plugin_weather_common/icons/64/nt_chancesnow.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/64/nt_chancetstorms.png b/piweatherrock/plugin_weather_common/icons/64/nt_chancetstorms.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/64/nt_clear.png b/piweatherrock/plugin_weather_common/icons/64/nt_clear.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/64/nt_cloudy.png b/piweatherrock/plugin_weather_common/icons/64/nt_cloudy.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/64/nt_flurries.png b/piweatherrock/plugin_weather_common/icons/64/nt_flurries.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/64/nt_fog.png b/piweatherrock/plugin_weather_common/icons/64/nt_fog.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/64/nt_hazy.png b/piweatherrock/plugin_weather_common/icons/64/nt_hazy.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/64/nt_mostlycloudy.png b/piweatherrock/plugin_weather_common/icons/64/nt_mostlycloudy.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/64/nt_mostlysunny.png b/piweatherrock/plugin_weather_common/icons/64/nt_mostlysunny.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/64/nt_partlycloudy.png b/piweatherrock/plugin_weather_common/icons/64/nt_partlycloudy.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/64/nt_partlysunny.png b/piweatherrock/plugin_weather_common/icons/64/nt_partlysunny.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/64/nt_rain.png b/piweatherrock/plugin_weather_common/icons/64/nt_rain.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/64/nt_sleet.png b/piweatherrock/plugin_weather_common/icons/64/nt_sleet.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/64/nt_snow.png b/piweatherrock/plugin_weather_common/icons/64/nt_snow.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/64/nt_sunny.png b/piweatherrock/plugin_weather_common/icons/64/nt_sunny.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/64/nt_tstorms.png b/piweatherrock/plugin_weather_common/icons/64/nt_tstorms.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/64/nt_unknown.png b/piweatherrock/plugin_weather_common/icons/64/nt_unknown.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/64/partlycloudy.png b/piweatherrock/plugin_weather_common/icons/64/partlycloudy.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/64/partlysunny.png b/piweatherrock/plugin_weather_common/icons/64/partlysunny.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/64/rain.png b/piweatherrock/plugin_weather_common/icons/64/rain.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/64/sleet.png b/piweatherrock/plugin_weather_common/icons/64/sleet.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/64/snow.png b/piweatherrock/plugin_weather_common/icons/64/snow.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/64/sunny.png b/piweatherrock/plugin_weather_common/icons/64/sunny.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/64/tstorms.png b/piweatherrock/plugin_weather_common/icons/64/tstorms.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/64/unknown.png b/piweatherrock/plugin_weather_common/icons/64/unknown.png old mode 100755 new mode 100644 diff --git a/piweatherrock/plugin_weather_common/icons/alt_icons/generate-dark-sky-pngs.sh b/piweatherrock/plugin_weather_common/icons/alt_icons/generate-dark-sky-pngs.sh old mode 100755 new mode 100644 diff --git a/piweatherrock/runner.py b/piweatherrock/runner.py index 508a50f..b4a76ca 100644 --- a/piweatherrock/runner.py +++ b/piweatherrock/runner.py @@ -41,6 +41,7 @@ def main(self, config_file): with open(config_file, "r") as f: self.config = json.load(f) + pygame.init() # Create an instance of the main application class self.my_weather_rock = Weather(config_file) diff --git a/piweatherrock/weather.py b/piweatherrock/weather.py index 2e299cb..7bf8ce5 100644 --- a/piweatherrock/weather.py +++ b/piweatherrock/weather.py @@ -15,7 +15,7 @@ import logging.handlers # third party imports -from darksky import forecast +from piweatherrock.climate import forecast import pygame import requests @@ -45,12 +45,13 @@ def __init__(self, config_file): #Initialize locale intl self.intl = intl() self.ui_lang = self.config["ui_lang"] - + + # Initialize logger + self.log = self.get_logger() + self.last_update_check = 0 self.weather = {} self.get_forecast() - # Initialize logger - self.log = self.get_logger() if platform.system() == 'Darwin': pygame.display.init() @@ -161,7 +162,8 @@ def get_forecast(self): self.config["lon"], exclude='minutely', units=self.config["units"], - lang=self.config["lang"]) + lang=self.config["lang"], + timezone=self.config["timezone"]) sunset_today = datetime.datetime.fromtimestamp( self.weather.daily[0].sunsetTime) diff --git a/requirements.txt b/requirements.txt index 4bd1cdf..bb73cb8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,9 @@ -darkskylib pygame pyserial requests cherrypy babel python-i18n +pytz piweatherrock-webconfig==1.5.0 diff --git a/scripts/pwr-ui b/scripts/pwr-ui old mode 100755 new mode 100644 From 4c9553d0bae8b9ee2e0b2951613b3b4e88dfc8db Mon Sep 17 00:00:00 2001 From: Carlos de Huerta Date: Sun, 30 Apr 2023 14:02:27 +0200 Subject: [PATCH 14/91] fix typo --- piweatherrock/climate/openmeteo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/piweatherrock/climate/openmeteo.py b/piweatherrock/climate/openmeteo.py index 7c6d5ad..b3846b6 100644 --- a/piweatherrock/climate/openmeteo.py +++ b/piweatherrock/climate/openmeteo.py @@ -217,7 +217,7 @@ def openmeteo_to_darksky(data, lang): "nearestStormDistance": 0, "precipIntensity": daily_data["precipitation_probability_min"][0], "precipIntensityError": 0, - "precipProbability": daily_data["precipitation_probability_mean"][0] /, + "precipProbability": daily_data["precipitation_probability_mean"][0] / 100, "precipType": "rain", "temperature": json_data["current_weather"]["temperature"], "apparentTemperature": json_data["current_weather"]["temperature"], From b64707421d4267c3d1cb7aa4d69a87b0f5bfe427 Mon Sep 17 00:00:00 2001 From: Carlos Date: Tue, 7 Nov 2023 21:27:21 +0100 Subject: [PATCH 15/91] Update forecast.py --- piweatherrock/climate/forecast.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/piweatherrock/climate/forecast.py b/piweatherrock/climate/forecast.py index d0bed03..6826888 100644 --- a/piweatherrock/climate/forecast.py +++ b/piweatherrock/climate/forecast.py @@ -89,9 +89,10 @@ def refresh(self, timeout=None, **queries): return super().__init__(data) else: - response = requests.get(self.url, **request_params) + response = requests.get(self.url) self.response_headers = response.headers if response.status_code != 200: + print(response.text) raise requests.exceptions.HTTPError('Bad response') - return super().__init__(openmeteo_to_darksky(response.text, queries["lang"])) \ No newline at end of file + return super().__init__(openmeteo_to_darksky(response.text, queries["lang"])) From f8f76f909674730faf09bd59c876e6225828d38a Mon Sep 17 00:00:00 2001 From: Carlos Date: Tue, 7 Nov 2023 21:29:26 +0100 Subject: [PATCH 16/91] Update forecast.py --- piweatherrock/climate/forecast.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/piweatherrock/climate/forecast.py b/piweatherrock/climate/forecast.py index 6826888..e455994 100644 --- a/piweatherrock/climate/forecast.py +++ b/piweatherrock/climate/forecast.py @@ -7,6 +7,15 @@ import requests from os import path +import logging +from http.client import HTTPConnection + +# Enable HTTPConnection debug logging to stdout. +log = logging.getLogger('urllib3') +log.setLevel(logging.DEBUG) +stream_handler = logging.StreamHandler(sys.stdout) +log.addHandler(stream_handler) + from .data import DataPoint from .openmeteo import * From 8f0a13dd98d200f63bd4668fb65defd07d3346c6 Mon Sep 17 00:00:00 2001 From: Chubascos1 <78501130+Chubascos1@users.noreply.github.com> Date: Mon, 12 Aug 2024 10:04:27 +0200 Subject: [PATCH 17/91] Update openmeteo.py --- piweatherrock/climate/openmeteo.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/piweatherrock/climate/openmeteo.py b/piweatherrock/climate/openmeteo.py index b3846b6..c5e6be5 100644 --- a/piweatherrock/climate/openmeteo.py +++ b/piweatherrock/climate/openmeteo.py @@ -12,14 +12,14 @@ def get_weather_translations(lang, wmocode): 2: {"en": "Partly cloudy", "es": "Parcialmente nublado"}, 3: {"en": "Overcast", "es": "Nublado"}, 45: {"en": "Fog", "es": "Niebla"}, - 48: {"en": "Depositing rime fog", "es": "Niebla depositada"}, - 51: {"en": "Drizzle: Light intensity", "es": "Llovizna: Intensidad ligera"}, - 53: {"en": "Drizzle: Moderate intensity", "es": "Llovizna: Intensidad moderada"}, - 55: {"en": "Drizzle: Dense intensity", "es": "Llovizna: Intensidad densa"}, - 56: {"en": "Freezing Drizzle: Light intensity", "es": "Llovizna helada: Intensidad ligera"}, - 57: {"en": "Freezing Drizzle: Dense intensity", "es": "Llovizna helada: Intensidad densa"}, - 61: {"en": "Rain: Slight intensity", "es": "Lluvia: Intensidad ligera"}, - 63: {"en": "Rain: Moderate intensity", "es": "Lluvia: Intensidad moderada"}, + 48: {"en": "Depositing rime fog", "es": "Niebla de escarcha"}, + 51: {"en": "Drizzle: Light intensity", "es": "Chispeo ligero"}, + 53: {"en": "Drizzle: Moderate intensity", "es": "Chispeo moderado"}, + 55: {"en": "Drizzle: Dense intensity", "es": "Chispeo intenso"}, + 56: {"en": "Freezing Drizzle: Light intensity", "es": "Lluvia engelante"}, + 57: {"en": "Freezing Drizzle: Dense intensity", "es": "Lluvia engelante intensa"}, + 61: {"en": "Rain: Slight intensity", "es": "Chubascos"}, + 63: {"en": "Rain: Moderate intensity", "es": "Chubascos"}, 65: {"en": "Rain: Heavy intensity", "es": "Lluvia: Intensidad fuerte"}, 66: {"en": "Freezing Rain: Light intensity", "es": "Lluvia helada: Intensidad ligera"}, 67: {"en": "Freezing Rain: Heavy intensity", "es": "Lluvia helada: Intensidad fuerte"}, @@ -233,4 +233,4 @@ def openmeteo_to_darksky(data, lang): "ozone": 0 } - return darksky_data \ No newline at end of file + return darksky_data From 41c59a63102a78611eb373a2ffe434cd7b6b1504 Mon Sep 17 00:00:00 2001 From: Chubascos1 <78501130+Chubascos1@users.noreply.github.com> Date: Mon, 12 Aug 2024 10:07:25 +0200 Subject: [PATCH 18/91] Update openmeteo.py --- piweatherrock/climate/openmeteo.py | 34 +++++++++++++++--------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/piweatherrock/climate/openmeteo.py b/piweatherrock/climate/openmeteo.py index c5e6be5..4ecb1e1 100644 --- a/piweatherrock/climate/openmeteo.py +++ b/piweatherrock/climate/openmeteo.py @@ -11,30 +11,30 @@ def get_weather_translations(lang, wmocode): 1: {"en": "Mainly clear", "es": "Mayormente despejado"}, 2: {"en": "Partly cloudy", "es": "Parcialmente nublado"}, 3: {"en": "Overcast", "es": "Nublado"}, - 45: {"en": "Fog", "es": "Niebla"}, + 45: {"en": "Fog", "es": "Neblina"}, 48: {"en": "Depositing rime fog", "es": "Niebla de escarcha"}, 51: {"en": "Drizzle: Light intensity", "es": "Chispeo ligero"}, 53: {"en": "Drizzle: Moderate intensity", "es": "Chispeo moderado"}, 55: {"en": "Drizzle: Dense intensity", "es": "Chispeo intenso"}, - 56: {"en": "Freezing Drizzle: Light intensity", "es": "Lluvia engelante"}, - 57: {"en": "Freezing Drizzle: Dense intensity", "es": "Lluvia engelante intensa"}, + 56: {"en": "Freezing Drizzle: Light intensity", "es": "Llovizna gélida"}, + 57: {"en": "Freezing Drizzle: Dense intensity", "es": "Llovizna gélida intensa"}, 61: {"en": "Rain: Slight intensity", "es": "Chubascos"}, 63: {"en": "Rain: Moderate intensity", "es": "Chubascos"}, - 65: {"en": "Rain: Heavy intensity", "es": "Lluvia: Intensidad fuerte"}, - 66: {"en": "Freezing Rain: Light intensity", "es": "Lluvia helada: Intensidad ligera"}, - 67: {"en": "Freezing Rain: Heavy intensity", "es": "Lluvia helada: Intensidad fuerte"}, - 71: {"en": "Snow fall: Slight intensity", "es": "Nevada: Intensidad ligera"}, - 73: {"en": "Snow fall: Moderate intensity", "es": "Nevada: Intensidad moderada"}, - 75: {"en": "Snow fall: Heavy intensity", "es": "Nevada: Intensidad fuerte"}, + 65: {"en": "Rain: Heavy intensity", "es": "Lluvia intensa"}, + 66: {"en": "Freezing Rain: Light intensity", "es": "Lluvia engelante"}, + 67: {"en": "Freezing Rain: Heavy intensity", "es": "Lluvia engelante intensa"}, + 71: {"en": "Snow fall: Slight intensity", "es": "Nevada"}, + 73: {"en": "Snow fall: Moderate intensity", "es": "Nevada densa"}, + 75: {"en": "Snow fall: Heavy intensity", "es": "Nevada intensa"}, 77: {"en": "Snow grains", "es": "Granos de nieve"}, - 80: {"en": "Rain showers: Slight intensity", "es": "Lluvias: Intensidad ligera"}, - 81: {"en": "Rain showers: Moderate intensity", "es": "Lluvias: Intensidad moderada"}, - 82: {"en": "Rain showers: Violent intensity", "es": "Lluvias: Intensidad fuerte"}, - 85: {"en": "Snow showers: Slight intensity", "es": "Nevadas: Intensidad ligera"}, - 86: {"en": "Snow showers: Heavy intensity", "es": "Nevadas: Intensidad fuerte"}, - 95: {"en": "Thunderstorm: Slight or moderate", "es": "Tormenta eléctrica: Ligera o moderada"}, - 96: {"en": "Thunderstorm with slight hail", "es": "Tormenta eléctrica con granizo ligero"}, - 99: {"en": "Thunderstorm with heavy hail", "es": "Tormenta eléctrica con granizo fuerte"} + 80: {"en": "Rain showers: Slight intensity", "es": "Chubascos"}, + 81: {"en": "Rain showers: Moderate intensity", "es": "Lluvia moderada"}, + 82: {"en": "Rain showers: Violent intensity", "es": "Lluvia fuerte"}, + 85: {"en": "Snow showers: Slight intensity", "es": "Nevadas"}, + 86: {"en": "Snow showers: Heavy intensity", "es": "Nevadas intensa"}, + 95: {"en": "Thunderstorm: Slight or moderate", "es": "Tormenta eléctrica"}, + 96: {"en": "Thunderstorm with slight hail", "es": "Tormenta eléctrica con granizo"}, + 99: {"en": "Thunderstorm with heavy hail", "es": "Tormenta eléctrica con granizado intenso"} } return weather_translations.get(wmocode, {}).get(lang, "Unknown" if lang == "en" else "Desconocido") From 247afca0f8bfdc562d12b74bfd71953db7adcc40 Mon Sep 17 00:00:00 2001 From: Chubascos1 <78501130+Chubascos1@users.noreply.github.com> Date: Mon, 12 Aug 2024 10:12:17 +0200 Subject: [PATCH 19/91] Update piweatherrock.es.json --- piweatherrock/intl/data/piweatherrock.es.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/piweatherrock/intl/data/piweatherrock.es.json b/piweatherrock/intl/data/piweatherrock.es.json index 0eac6bd..e612829 100644 --- a/piweatherrock/intl/data/piweatherrock.es.json +++ b/piweatherrock/intl/data/piweatherrock.es.json @@ -2,9 +2,9 @@ "es": { "feels_like": "Sensación térmica:", "wind":"Viento:", - "humidity":"Humedad:", - "umbrella":"¡Coge el paraguas!", - "no_umbrella":"Hoy no cojas el paraguas", + "humidity":"Humedad absoluta:", + "umbrella":"¡Rayos y centellas!", + "no_umbrella":"Hoy no cojas el paraguas, coge la sombrilla", "today":"hoy", "powered_by":"Weather rock gracias a Dark Sky", "tonight":"esta noche", From ef3a3b88b7ddf4d76c5a962a1ead1703ff075183 Mon Sep 17 00:00:00 2001 From: Chubascos1 <78501130+Chubascos1@users.noreply.github.com> Date: Mon, 12 Aug 2024 10:12:46 +0200 Subject: [PATCH 20/91] Update openmeteo.py --- piweatherrock/climate/openmeteo.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/piweatherrock/climate/openmeteo.py b/piweatherrock/climate/openmeteo.py index 4ecb1e1..f652f8d 100644 --- a/piweatherrock/climate/openmeteo.py +++ b/piweatherrock/climate/openmeteo.py @@ -16,8 +16,8 @@ def get_weather_translations(lang, wmocode): 51: {"en": "Drizzle: Light intensity", "es": "Chispeo ligero"}, 53: {"en": "Drizzle: Moderate intensity", "es": "Chispeo moderado"}, 55: {"en": "Drizzle: Dense intensity", "es": "Chispeo intenso"}, - 56: {"en": "Freezing Drizzle: Light intensity", "es": "Llovizna gélida"}, - 57: {"en": "Freezing Drizzle: Dense intensity", "es": "Llovizna gélida intensa"}, + 56: {"en": "Freezing Drizzle: Light intensity", "es": "Llovizna engelante"}, + 57: {"en": "Freezing Drizzle: Dense intensity", "es": "Llovizna engelante intensa"}, 61: {"en": "Rain: Slight intensity", "es": "Chubascos"}, 63: {"en": "Rain: Moderate intensity", "es": "Chubascos"}, 65: {"en": "Rain: Heavy intensity", "es": "Lluvia intensa"}, From ad6ad87faf49ee6921f8b78cad221ef110343e9e Mon Sep 17 00:00:00 2001 From: Carlos Date: Mon, 12 Aug 2024 10:23:09 +0200 Subject: [PATCH 21/91] Create piweatherrock-config.json --- piweatherrock/piweatherrock-config.json | 26 +++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 piweatherrock/piweatherrock-config.json diff --git a/piweatherrock/piweatherrock-config.json b/piweatherrock/piweatherrock-config.json new file mode 100644 index 0000000..20a6a4b --- /dev/null +++ b/piweatherrock/piweatherrock-config.json @@ -0,0 +1,26 @@ +{ + "ds_api_key": "openmeteo-request-piweatherrock", + "lat": 40.299457, + "lon": -3.743399, + "units": "si", + "lang": "es", + "ui_lang":"es", + "timezone":"Europe/Madrid", + "fullscreen": true, + "12hour_disp": false, + "icon_offset": -23.5, + "update_freq": 900, + "info_pause": 60, + "info_delay": 900, + "plugins": { + "daily": { + "pause": 60, + "enabled": false + }, + "hourly": { + "pause": 60, + "enabled": false + } + }, + "log_level": "INFO" +} From e506603722e6f390a1bae619a80ef4abbae069ed Mon Sep 17 00:00:00 2001 From: Carlos Date: Mon, 12 Aug 2024 10:32:50 +0200 Subject: [PATCH 22/91] Update README.md --- README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/README.md b/README.md index bcc1683..828c618 100644 --- a/README.md +++ b/README.md @@ -17,3 +17,22 @@ More information about the project and full documentation can be found at https: - [optional] `twine upload --repository-url https://test.pypi.org/legacy/ dist/*` - `twine upload dist/*` - Create a git tag and push it + +## Local Development process + +- git clone https://github.com/carloshm/PiWeatherRock.git +- git pull (for any additional external change after a while) +- cd PiWeatherRock +- Make changes +- git add . +- git commit -m "changes description" +- git push origin main + +## Run changes + +- python3 setup.py install --user +- python3 ./scripts/pwr-ui -c /home/pi/Desktop/piweatherrock-config.json + +## Validate Service Data + +https://api.open-meteo.com:443 "GET /v1/forecast?latitude=40.299457&longitude=-3.743399&appid=openmeteo-request-piweatherrock&timezone=Europe/Madrid&models=best_match&forecast_days=4¤t_weather=true&temperature_unit=celsius&windspeed_unit=kmh&precipitation_unit=mm&timeformat=iso8601&hourly=visibility,weathercode,temperature_2m,relativehumidity_2m,apparent_temperature,surface_pressure,cloudcover,windspeed_80m,precipitation,precipitation_probability,dewpoint_2m,windspeed_10m,windgusts_10m,winddirection_10m,cloudcover_low,direct_radiation&daily=sunrise,sunset,uv_index_max,weathercode,temperature_2m_max,temperature_2m_min,apparent_temperature_max,apparent_temperature_min,precipitation_sum,precipitation_probability_mean,precipitation_probability_min,windgusts_10m_max,precipitation_probability_max,windspeed_10m_max,winddirection_10m_dominant HTTP/1.1" From a91abc72a1c4f7e279e5116c906a3c214ead2b4f Mon Sep 17 00:00:00 2001 From: Carlos Date: Mon, 12 Aug 2024 10:33:51 +0200 Subject: [PATCH 23/91] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 828c618..cc64d25 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,8 @@ More information about the project and full documentation can be found at https: ## Local Development process - git clone https://github.com/carloshm/PiWeatherRock.git -- git pull (for any additional external change after a while) - cd PiWeatherRock +- git pull (for any additional external change after a while) - Make changes - git add . - git commit -m "changes description" From c63fc5028e7e560a8030400e9838234d192e37f5 Mon Sep 17 00:00:00 2001 From: Carlos Date: Mon, 12 Aug 2024 10:38:55 +0200 Subject: [PATCH 24/91] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index cc64d25..ba3eaee 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,9 @@ More information about the project and full documentation can be found at https: - git commit -m "changes description" - git push origin main -## Run changes +## Run changes https://blog.ganssle.io/articles/2021/10/setup-py-deprecated.html -- python3 setup.py install --user +- pip install -r requirements.txt - python3 ./scripts/pwr-ui -c /home/pi/Desktop/piweatherrock-config.json ## Validate Service Data From dd7c5078f39465fb6264c028217d7cbe1b963a30 Mon Sep 17 00:00:00 2001 From: Carlos Date: Mon, 12 Aug 2024 10:40:25 +0200 Subject: [PATCH 25/91] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ba3eaee..7d8f845 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ More information about the project and full documentation can be found at https: ## Run changes https://blog.ganssle.io/articles/2021/10/setup-py-deprecated.html - pip install -r requirements.txt -- python3 ./scripts/pwr-ui -c /home/pi/Desktop/piweatherrock-config.json +- python3 ./scripts/pwr-ui -c ./piweatherrock/piweatherrock-config.json ## Validate Service Data From 82c024c6c336f721d26c896aeaa2a17fc4a4f3d6 Mon Sep 17 00:00:00 2001 From: Carlos Date: Mon, 12 Aug 2024 10:47:21 +0200 Subject: [PATCH 26/91] Update README.md --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7d8f845..8b8d1c0 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,11 @@ More information about the project and full documentation can be found at https: ## Local Development process +- python3 -m venv env_name +- source env_name/bin/activate + - git clone https://github.com/carloshm/PiWeatherRock.git -- cd PiWeatherRock +- cd PiWeatherRock - git pull (for any additional external change after a while) - Make changes - git add . From c704e40af5b710feb6737b6539f962abc0d93def Mon Sep 17 00:00:00 2001 From: Carlos Date: Mon, 12 Aug 2024 10:52:55 +0200 Subject: [PATCH 27/91] Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 8b8d1c0..4e61e6c 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ More information about the project and full documentation can be found at https: ## Run changes https://blog.ganssle.io/articles/2021/10/setup-py-deprecated.html +- python3 -m pip install --upgrade setuptools wheel - pip install -r requirements.txt - python3 ./scripts/pwr-ui -c ./piweatherrock/piweatherrock-config.json From be4a97158449946097dab3afc560b70b89d06687 Mon Sep 17 00:00:00 2001 From: Carlos Date: Mon, 12 Aug 2024 10:54:50 +0200 Subject: [PATCH 28/91] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4e61e6c..85c174b 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ More information about the project and full documentation can be found at https: ## Run changes https://blog.ganssle.io/articles/2021/10/setup-py-deprecated.html - python3 -m pip install --upgrade setuptools wheel -- pip install -r requirements.txt +- python3 -m pip install . - python3 ./scripts/pwr-ui -c ./piweatherrock/piweatherrock-config.json ## Validate Service Data From ad9bd63434f04305a2f54bdff513519095a7a470 Mon Sep 17 00:00:00 2001 From: Carlos Date: Mon, 12 Aug 2024 10:56:58 +0200 Subject: [PATCH 29/91] Update README.md --- README.md | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 85c174b..a5f382d 100644 --- a/README.md +++ b/README.md @@ -20,22 +20,32 @@ More information about the project and full documentation can be found at https: ## Local Development process -- python3 -m venv env_name -- source env_name/bin/activate - -- git clone https://github.com/carloshm/PiWeatherRock.git -- cd PiWeatherRock -- git pull (for any additional external change after a while) -- Make changes -- git add . -- git commit -m "changes description" -- git push origin main +```python +python3 -m venv env_name +source env_name/bin/activate +``` + +```python +git clone https://github.com/carloshm/PiWeatherRock.git +cd PiWeatherRock +git pull (for any additional external change after a while) +``` + +Make changes + +```python +git add . +git commit -m "changes description" +git push origin main +``` ## Run changes https://blog.ganssle.io/articles/2021/10/setup-py-deprecated.html -- python3 -m pip install --upgrade setuptools wheel -- python3 -m pip install . -- python3 ./scripts/pwr-ui -c ./piweatherrock/piweatherrock-config.json +```python +python3 -m pip install --upgrade setuptools wheel +python3 -m pip install . +python3 ./scripts/pwr-ui -c ./piweatherrock/piweatherrock-config.json +``` ## Validate Service Data From dec50dd6b817651f806d31a85fca6831ea37280c Mon Sep 17 00:00:00 2001 From: Fortunata-bit <78501130+Fortunata-bit@users.noreply.github.com> Date: Mon, 12 Aug 2024 10:57:59 +0200 Subject: [PATCH 30/91] Update piweatherrock.es.json --- piweatherrock/intl/data/piweatherrock.es.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/piweatherrock/intl/data/piweatherrock.es.json b/piweatherrock/intl/data/piweatherrock.es.json index e612829..5b5fdda 100644 --- a/piweatherrock/intl/data/piweatherrock.es.json +++ b/piweatherrock/intl/data/piweatherrock.es.json @@ -4,7 +4,7 @@ "wind":"Viento:", "humidity":"Humedad absoluta:", "umbrella":"¡Rayos y centellas!", - "no_umbrella":"Hoy no cojas el paraguas, coge la sombrilla", + "no_umbrella":"Hoy no cojas el paraguas!", "today":"hoy", "powered_by":"Weather rock gracias a Dark Sky", "tonight":"esta noche", From 2ae4f0214e3388b3687b6be3cba11e5fb675c095 Mon Sep 17 00:00:00 2001 From: Fortunata-bit <78501130+Fortunata-bit@users.noreply.github.com> Date: Mon, 12 Aug 2024 10:58:07 +0200 Subject: [PATCH 31/91] Update piweatherrock.es.json --- piweatherrock/intl/data/piweatherrock.es.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/piweatherrock/intl/data/piweatherrock.es.json b/piweatherrock/intl/data/piweatherrock.es.json index 5b5fdda..cd4be14 100644 --- a/piweatherrock/intl/data/piweatherrock.es.json +++ b/piweatherrock/intl/data/piweatherrock.es.json @@ -4,7 +4,7 @@ "wind":"Viento:", "humidity":"Humedad absoluta:", "umbrella":"¡Rayos y centellas!", - "no_umbrella":"Hoy no cojas el paraguas!", + "no_umbrella":"Hoy no cojas el paraguas", "today":"hoy", "powered_by":"Weather rock gracias a Dark Sky", "tonight":"esta noche", From d4bd30682c8f4cc0f2f109f2c13d0f960cc3b394 Mon Sep 17 00:00:00 2001 From: Carlos Date: Mon, 12 Aug 2024 11:31:01 +0200 Subject: [PATCH 32/91] Update __init__.py --- piweatherrock/plugin_weather_common/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/piweatherrock/plugin_weather_common/__init__.py b/piweatherrock/plugin_weather_common/__init__.py index a13531f..ca2f567 100644 --- a/piweatherrock/plugin_weather_common/__init__.py +++ b/piweatherrock/plugin_weather_common/__init__.py @@ -462,7 +462,7 @@ def icon_mapping(self, icon, size): Based on that, this method will map the Dark Sky icon name to the name of an icon in this project. """ - if icon == 'clear-day': + if icon == 'clear-day' or icon == 'clear': icon_path = 'icons/{}/clear.png'.format(size) elif icon == 'clear-night': icon_path = 'icons/{}/nt_clear.png'.format(size) From dfb5d448547b91bea778f413fcb7a7680a9b5e83 Mon Sep 17 00:00:00 2001 From: Carlos Date: Mon, 12 Aug 2024 12:17:05 +0200 Subject: [PATCH 33/91] Update __init__.py --- piweatherrock/plugin_weather_common/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/piweatherrock/plugin_weather_common/__init__.py b/piweatherrock/plugin_weather_common/__init__.py index ca2f567..b79c4c2 100644 --- a/piweatherrock/plugin_weather_common/__init__.py +++ b/piweatherrock/plugin_weather_common/__init__.py @@ -468,6 +468,8 @@ def icon_mapping(self, icon, size): icon_path = 'icons/{}/nt_clear.png'.format(size) elif icon == 'rain': icon_path = 'icons/{}/rain.png'.format(size) + elif icon == 'chancerain': + icon_path = 'icons/{}/chancerain.png'.format(size) elif icon == 'snow': icon_path = 'icons/{}/snow.png'.format(size) elif icon == 'sleet': @@ -478,9 +480,9 @@ def icon_mapping(self, icon, size): icon_path = 'icons/{}/fog.png'.format(size) elif icon == 'cloudy': icon_path = 'icons/{}/cloudy.png'.format(size) - elif icon == 'partly-cloudy-day': + elif icon == 'partly-cloudy-day' or icon == 'partlycloudy': icon_path = 'icons/{}/partlycloudy.png'.format(size) - elif icon == 'partly-cloudy-night': + elif icon == 'partly-cloudy-night' or icon == 'nt_partlycloudy': icon_path = 'icons/{}/nt_partlycloudy.png'.format(size) else: icon_path = 'icons/{}/unknown.png'.format(size) From b06fe1a3b2f9d41983b03cd905b2f4c1a210522c Mon Sep 17 00:00:00 2001 From: Carlos Date: Mon, 12 Aug 2024 12:37:58 +0200 Subject: [PATCH 34/91] Update __init__.py --- .../plugin_weather_common/__init__.py | 34 +++++++++++++------ 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/piweatherrock/plugin_weather_common/__init__.py b/piweatherrock/plugin_weather_common/__init__.py index b79c4c2..9627726 100644 --- a/piweatherrock/plugin_weather_common/__init__.py +++ b/piweatherrock/plugin_weather_common/__init__.py @@ -462,28 +462,40 @@ def icon_mapping(self, icon, size): Based on that, this method will map the Dark Sky icon name to the name of an icon in this project. """ - if icon == 'clear-day' or icon == 'clear': + if icon == 'clear': icon_path = 'icons/{}/clear.png'.format(size) - elif icon == 'clear-night': - icon_path = 'icons/{}/nt_clear.png'.format(size) + elif icon == 'mostlysunny': + icon_path = 'icons/{}/mostlysunny.png'.format(size) + elif icon == 'partlycloudy': + icon_path = 'icons/{}/partlycloudy.png'.format(size) + elif icon == 'cloudy': + icon_path = 'icons/{}/cloudy.png'.format(size) + elif icon == 'fog': + icon_path = 'icons/{}/fog.png'.format(size) + elif icon == 'hazy': + icon_path = 'icons/{}/hazy.png'.format(size) elif icon == 'rain': icon_path = 'icons/{}/rain.png'.format(size) elif icon == 'chancerain': icon_path = 'icons/{}/chancerain.png'.format(size) + elif icon == 'chainsleet': + icon_path = 'icons/{}/chainsleet.png'.format(size) elif icon == 'snow': icon_path = 'icons/{}/snow.png'.format(size) elif icon == 'sleet': icon_path = 'icons/{}/sleet.png'.format(size) elif icon == 'wind': icon_path = 'icons/alt_icons/{}/wind.png'.format(size) - elif icon == 'fog': - icon_path = 'icons/{}/fog.png'.format(size) - elif icon == 'cloudy': - icon_path = 'icons/{}/cloudy.png'.format(size) - elif icon == 'partly-cloudy-day' or icon == 'partlycloudy': - icon_path = 'icons/{}/partlycloudy.png'.format(size) - elif icon == 'partly-cloudy-night' or icon == 'nt_partlycloudy': - icon_path = 'icons/{}/nt_partlycloudy.png'.format(size) + elif icon == 'chancesnow': + icon_path = 'icons/alt_icons/{}/chancesnow.png'.format(size) + elif icon == 'tstorms' or icon == 'tstorm': + icon_path = 'icons/alt_icons/{}/tstorm.png'.format(size) + elif icon == 'chanceflurries': + icon_path = 'icons/alt_icons/{}/chanceflurries.png'.format(size) + elif icon == 'flurries': + icon_path = 'icons/alt_icons/{}/flurries.png'.format(size) + elif icon == 'chancetstorms': + icon_path = 'icons/alt_icons/{}/chancetstorms.png'.format(size) else: icon_path = 'icons/{}/unknown.png'.format(size) From bd0da1f827235e5a34af912cc0193322b0237a60 Mon Sep 17 00:00:00 2001 From: Carlos Date: Wed, 28 Aug 2024 16:21:01 +0200 Subject: [PATCH 35/91] Add files via upload --- .../icons/alt_icons/64/tstorm.png | Bin 0 -> 1231 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 piweatherrock/plugin_weather_common/icons/alt_icons/64/tstorm.png diff --git a/piweatherrock/plugin_weather_common/icons/alt_icons/64/tstorm.png b/piweatherrock/plugin_weather_common/icons/alt_icons/64/tstorm.png new file mode 100644 index 0000000000000000000000000000000000000000..285f9608301326abfd981cd41710253dd6bde89e GIT binary patch literal 1231 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I3?%1nZ+yeRz?c`{6XFWw{>K1v^+zCL-l{+#UW}SR)1Y4FIavMYF!J+Z4mQ+uMh3xf4G?rxgsqnBpzIx{P zu}yROnnD~b%?&lB1$kMRfBn3B`_S&KTi33dGP$laCoVA1-^t!kS6z|w-|ZXMuU$R3 zf5U>=Q@T1kI@;=LYbr{L^HY-(!<~$Db#!!;<@xz|j-TK4pMimCj;D)bNW|f{SJTUH z8Az}`;6AIA;=s`;U^Hv5`)rfkG~?Sn@AiJb_jc-n|NDewl0OIt*nOR26VI~gZ;@&w z)AE1(dmpNq9a;aVg=xzb*#%X1b;R7aChd&(TPi3cpS{4SdDWK5JconU@Aq4%^s?VN z_W;99gKnk6Dz^i!oiW%tAx~zy&?=Qvk_*@J^J#^cpRw}PV++a`{<3GfO3y2gzG(S} z*IeHnSTHq3fNlRtZ-X7K*A90q`B}t~R+!vaRm7bpeT+%?iObK>w)HE|Z3=E+{2#tj z-|dy9nextwpJE-&dgnD>lzg`5gWlc1e>WtPthcIKm`-%o@XtT?IB@P&*|YDo;@h5R zd9}nupYKUozj(2*+q)yNec>N$S3R;&2>1N|^|#HA*E`oAPgrd%D&SNh_oH;_Yz0>N zW+#)wsy=F7?Iq0jzS%G@I6TqPg{_vo;K|0qIj6s!e1D*i>*ERShV1)#6AgFR3M;*; z{kE-I=gp$m20t`gA`0^F%=oKxt8Zm#E*n$h#zV`(&gUN%;$WY0wt&6%pUUGcPpiYa z!VMgwSBS>(xp{H>h}rk+MztM#m|tcl!Lw}QWYMJD->F73Yc?%XU!*G=5>*|za)n06 zBbI9}2jfF>{xVNF-2VB%yNh@3+*!|hN91O$Mt{6>W=wv|!BkEDP-SDmh;OYs*4>_V zW~*GSrO@Jx!<52uOr`Vri{FKUN{NYrUc<7g(F%2D-2V%38 zWRE`#*ZlpNaoNLsyZuh@pUAFW9@+8yMCX&PZ#&Ws9=Ht%T-kr>mdKI;Vst05tTR@Bjb+ literal 0 HcmV?d00001 From e6f52c2ca76dd8409f0ae3596b98e3d4e1f773b0 Mon Sep 17 00:00:00 2001 From: Carlos Date: Wed, 28 Aug 2024 16:22:11 +0200 Subject: [PATCH 36/91] Add files via upload --- .../icons/alt_icons/256/tstorm.png | Bin 0 -> 4543 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 piweatherrock/plugin_weather_common/icons/alt_icons/256/tstorm.png diff --git a/piweatherrock/plugin_weather_common/icons/alt_icons/256/tstorm.png b/piweatherrock/plugin_weather_common/icons/alt_icons/256/tstorm.png new file mode 100644 index 0000000000000000000000000000000000000000..cdd3111175e26f8644bd9e403e2e90a2994500d2 GIT binary patch literal 4543 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 Date: Wed, 28 Aug 2024 16:22:40 +0200 Subject: [PATCH 37/91] Add files via upload --- .../plugin_weather_common/icons/256/tstorm.png | Bin 0 -> 4543 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 piweatherrock/plugin_weather_common/icons/256/tstorm.png diff --git a/piweatherrock/plugin_weather_common/icons/256/tstorm.png b/piweatherrock/plugin_weather_common/icons/256/tstorm.png new file mode 100644 index 0000000000000000000000000000000000000000..cdd3111175e26f8644bd9e403e2e90a2994500d2 GIT binary patch literal 4543 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 Date: Wed, 28 Aug 2024 16:23:27 +0200 Subject: [PATCH 38/91] Add files via upload --- .../plugin_weather_common/icons/64/tstorm.png | Bin 0 -> 1231 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 piweatherrock/plugin_weather_common/icons/64/tstorm.png diff --git a/piweatherrock/plugin_weather_common/icons/64/tstorm.png b/piweatherrock/plugin_weather_common/icons/64/tstorm.png new file mode 100644 index 0000000000000000000000000000000000000000..285f9608301326abfd981cd41710253dd6bde89e GIT binary patch literal 1231 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I3?%1nZ+yeRz?c`{6XFWw{>K1v^+zCL-l{+#UW}SR)1Y4FIavMYF!J+Z4mQ+uMh3xf4G?rxgsqnBpzIx{P zu}yROnnD~b%?&lB1$kMRfBn3B`_S&KTi33dGP$laCoVA1-^t!kS6z|w-|ZXMuU$R3 zf5U>=Q@T1kI@;=LYbr{L^HY-(!<~$Db#!!;<@xz|j-TK4pMimCj;D)bNW|f{SJTUH z8Az}`;6AIA;=s`;U^Hv5`)rfkG~?Sn@AiJb_jc-n|NDewl0OIt*nOR26VI~gZ;@&w z)AE1(dmpNq9a;aVg=xzb*#%X1b;R7aChd&(TPi3cpS{4SdDWK5JconU@Aq4%^s?VN z_W;99gKnk6Dz^i!oiW%tAx~zy&?=Qvk_*@J^J#^cpRw}PV++a`{<3GfO3y2gzG(S} z*IeHnSTHq3fNlRtZ-X7K*A90q`B}t~R+!vaRm7bpeT+%?iObK>w)HE|Z3=E+{2#tj z-|dy9nextwpJE-&dgnD>lzg`5gWlc1e>WtPthcIKm`-%o@XtT?IB@P&*|YDo;@h5R zd9}nupYKUozj(2*+q)yNec>N$S3R;&2>1N|^|#HA*E`oAPgrd%D&SNh_oH;_Yz0>N zW+#)wsy=F7?Iq0jzS%G@I6TqPg{_vo;K|0qIj6s!e1D*i>*ERShV1)#6AgFR3M;*; z{kE-I=gp$m20t`gA`0^F%=oKxt8Zm#E*n$h#zV`(&gUN%;$WY0wt&6%pUUGcPpiYa z!VMgwSBS>(xp{H>h}rk+MztM#m|tcl!Lw}QWYMJD->F73Yc?%XU!*G=5>*|za)n06 zBbI9}2jfF>{xVNF-2VB%yNh@3+*!|hN91O$Mt{6>W=wv|!BkEDP-SDmh;OYs*4>_V zW~*GSrO@Jx!<52uOr`Vri{FKUN{NYrUc<7g(F%2D-2V%38 zWRE`#*ZlpNaoNLsyZuh@pUAFW9@+8yMCX&PZ#&Ws9=Ht%T-kr>mdKI;Vst05tTR@Bjb+ literal 0 HcmV?d00001 From d79006837436ec5acd11c0d407416ebce5b29da1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 18:53:13 +0000 Subject: [PATCH 39/91] docs: update README and documentation to reflect Open-Meteo API migration Agent-Logs-Url: https://github.com/carloshm/PiWeatherRock/sessions/8f45f5f4-411d-40db-99f2-4e7ffc194b42 Co-authored-by: carloshm <2389573+carloshm@users.noreply.github.com> --- CHANGELOG.md | 8 ++++++ README.md | 25 ++++++++++++++++++- piweatherrock/climate/data.py | 2 +- .../plugin_weather_common/__init__.py | 18 ++++++------- piweatherrock/runner.py | 4 +-- piweatherrock/weather.py | 2 +- 6 files changed, 43 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cafd906..bf1919d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Change log +## [3.0.0](https://github.com/carloshm/PiWeatherRock) + +- Migrated weather API from Dark Sky to [Open-Meteo](https://open-meteo.com/) +- Added `openmeteo.py` module to translate Open-Meteo responses to the internal Dark Sky data format +- No API key is required for non-commercial use with Open-Meteo +- Added internationalization support with `intl` module +- Updated configuration to use Open-Meteo endpoint + ## [2.1.0](https://github.com/genebean/PiWeatherRock/tree/2.1.0) - Add option for 24h time diff --git a/README.md b/README.md index a5f382d..bfd2d15 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,23 @@ PiWeatherRock displays local weather on (almost) any screen you connect to a Ras More information about the project and full documentation can be found at https://piweatherrock.technicalissues.us. Be sure to check out the getting started guide under the documentation link there for instruction on how to set everything up. +## Weather API + +This project uses the [Open-Meteo API](https://open-meteo.com/) to fetch weather data. Open-Meteo is a free, open-source weather API that does not require an API key for non-commercial use. + +> **Note:** Previous versions of PiWeatherRock used the [Dark Sky API](https://darksky.net/), which was shut down. The project has been migrated to use Open-Meteo as a drop-in replacement. The internal data format still follows the Dark Sky structure for backward compatibility, with the `openmeteo.py` module handling the translation between APIs. + +### Configuration + +Weather settings are configured in `piweatherrock/piweatherrock-config.json`: + +- `ds_api_key`: Identifier for the Open-Meteo request (no real API key needed). The name is a legacy reference from the Dark Sky era, kept for backward compatibility. +- `lat` / `lon`: Your location coordinates. +- `units`: Unit system (`si` for metric). +- `lang`: Language for weather descriptions. +- `timezone`: Your timezone (e.g., `Europe/Madrid`). +- `update_freq`: How often to refresh weather data (in seconds). + ## Release process - edit `version.py` according to the types of changes made @@ -49,4 +66,10 @@ python3 ./scripts/pwr-ui -c ./piweatherrock/piweatherrock-config.json ## Validate Service Data -https://api.open-meteo.com:443 "GET /v1/forecast?latitude=40.299457&longitude=-3.743399&appid=openmeteo-request-piweatherrock&timezone=Europe/Madrid&models=best_match&forecast_days=4¤t_weather=true&temperature_unit=celsius&windspeed_unit=kmh&precipitation_unit=mm&timeformat=iso8601&hourly=visibility,weathercode,temperature_2m,relativehumidity_2m,apparent_temperature,surface_pressure,cloudcover,windspeed_80m,precipitation,precipitation_probability,dewpoint_2m,windspeed_10m,windgusts_10m,winddirection_10m,cloudcover_low,direct_radiation&daily=sunrise,sunset,uv_index_max,weathercode,temperature_2m_max,temperature_2m_min,apparent_temperature_max,apparent_temperature_min,precipitation_sum,precipitation_probability_mean,precipitation_probability_min,windgusts_10m_max,precipitation_probability_max,windspeed_10m_max,winddirection_10m_dominant HTTP/1.1" +You can test the Open-Meteo API directly with a request like this: + +``` +https://api.open-meteo.com/v1/forecast?latitude=40.299457&longitude=-3.743399&timezone=Europe/Madrid&models=best_match&forecast_days=4¤t_weather=true&temperature_unit=celsius&windspeed_unit=kmh&precipitation_unit=mm&timeformat=iso8601&hourly=visibility,weathercode,temperature_2m,relativehumidity_2m,apparent_temperature,surface_pressure,cloudcover,windspeed_80m,precipitation,precipitation_probability,dewpoint_2m,windspeed_10m,windgusts_10m,winddirection_10m,cloudcover_low,direct_radiation&daily=sunrise,sunset,uv_index_max,weathercode,temperature_2m_max,temperature_2m_min,apparent_temperature_max,apparent_temperature_min,precipitation_sum,precipitation_probability_mean,precipitation_probability_min,windgusts_10m_max,precipitation_probability_max,windspeed_10m_max,winddirection_10m_dominant +``` + +For more details on available parameters, see the [Open-Meteo API documentation](https://open-meteo.com/en/docs). diff --git a/piweatherrock/climate/data.py b/piweatherrock/climate/data.py index 1e26725..e064c3c 100644 --- a/piweatherrock/climate/data.py +++ b/piweatherrock/climate/data.py @@ -44,7 +44,7 @@ def __iter__(self): return self.data.__iter__() def __getitem__(self, index): - # keys in darksky API datablocks are always str + # keys in weather API datablocks are always str if isinstance(index, str): return self._data[index] return self.data.__getitem__(index) diff --git a/piweatherrock/plugin_weather_common/__init__.py b/piweatherrock/plugin_weather_common/__init__.py index 9627726..e895b2f 100644 --- a/piweatherrock/plugin_weather_common/__init__.py +++ b/piweatherrock/plugin_weather_common/__init__.py @@ -328,9 +328,9 @@ def get_abbreviation(self, phrase): def units_decoder(self, units): """ - https://darksky.net/dev/docs has lists out what each - unit is. The method below is just a codified version - of what is on that page. + Decodes the unit system for weather data. + Originally based on Dark Sky API unit definitions, + now used with Open-Meteo API data (translated via openmeteo.py). """ si_dict = { 'nearestStormDistance': 'Kilometers', @@ -449,15 +449,11 @@ def display_subwindow(self, data, day, c_times): def icon_mapping(self, icon, size): """ - https://darksky.net/dev/docs has this to say about icons: - icon optional - A machine-readable text summary of this data point, suitable for - selecting an icon for display. If defined, this property will have one - of the following values: clear-day, clear-night, rain, snow, sleet, + Maps weather icon codes to image files for display. + Icon values follow the Dark Sky convention (used internally for + backward compatibility): clear-day, clear-night, rain, snow, sleet, wind, fog, cloudy, partly-cloudy-day, or partly-cloudy-night. - (Developers should ensure that a sensible default is defined, as - additional values, such as hail, thunderstorm, or tornado, may be - defined in the future.) + The Open-Meteo WMO codes are translated to these values in openmeteo.py. Based on that, this method will map the Dark Sky icon name to the name of an icon in this project. diff --git a/piweatherrock/runner.py b/piweatherrock/runner.py index b4a76ca..0869a11 100644 --- a/piweatherrock/runner.py +++ b/piweatherrock/runner.py @@ -68,10 +68,10 @@ def main(self, config_file): # Switch to info periodically to prevent screen burn. self.periodic_info_activation = 0 - # Loads data from darksky.net + # Loads data from Open-Meteo API if not self.my_weather_rock.get_forecast(): self.my_weather_rock.log.exception( - "Error: no data from darksky.net.") + "Error: no data from Open-Meteo API.") self.running = False ################################################################## diff --git a/piweatherrock/weather.py b/piweatherrock/weather.py index 7bf8ce5..d5cb3cc 100644 --- a/piweatherrock/weather.py +++ b/piweatherrock/weather.py @@ -35,7 +35,7 @@ def exit_gracefully(signum, frame): class Weather: """ - Fetches weather reports from Dark Sky for displaying on a screen. + Fetches weather reports from Open-Meteo API for displaying on a screen. """ def __init__(self, config_file): From 6af91a39ddab7b967f58313b6c36fc029d5e4c60 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 18:58:09 +0000 Subject: [PATCH 40/91] Add consolidated project audit Agent-Logs-Url: https://github.com/carloshm/PiWeatherRock/sessions/346f40e5-72fb-4dff-be08-73ceae6ac4bd Co-authored-by: carloshm <2389573+carloshm@users.noreply.github.com> --- AUDITORIA_PROYECTO.md | 409 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 409 insertions(+) create mode 100644 AUDITORIA_PROYECTO.md diff --git a/AUDITORIA_PROYECTO.md b/AUDITORIA_PROYECTO.md new file mode 100644 index 0000000..0bacb6d --- /dev/null +++ b/AUDITORIA_PROYECTO.md @@ -0,0 +1,409 @@ +# Auditoría consolidada de PiWeatherRock + +## Alcance + +Revisión estática del proyecto PiWeatherRock con foco en: + +- Bugs observables en ejecución, especialmente iconos meteorológicos no esperados. +- Riesgos de disponibilidad en Raspberry Pi. +- Evolución funcional para sustituir la pantalla de información rotativa por un modo “marco de fotos” con imágenes a pantalla completa. +- Evolución de configuración para seleccionar qué páginas se muestran y parametrizarlas desde una aplicación de configuración. + +## Resumen ejecutivo + +El proyecto conserva una arquitectura sencilla basada en `pygame`: `Runner` carga la configuración, crea el objeto `Weather`, instancia los plugins diario, horario e información, y rota entre pantallas mediante contadores de tiempo. La integración meteorológica actual adapta Open-Meteo al modelo histórico de Dark Sky. + +Los principales riesgos detectados son: + +1. **Iconos no robustos**: hay mapeos hacia ficheros inexistentes y errores tipográficos que pueden provocar fallos en `pygame.image.load()`. +2. **Primer arranque frágil si falla la API**: un error de red inicial puede dejar `self.weather` vacío y aun así continuar la ejecución. +3. **Configuración incoherente**: el ejemplo de configuración no incluye `timezone`, aunque el código lo exige. +4. **Plugins `enabled` ignorados**: la configuración permite habilitar/deshabilitar páginas, pero `Runner` rota siempre entre diario, horario e info. +5. **Disponibilidad en Raspberry Pi mejorable**: no hay timeout efectivo en peticiones HTTP, la inicialización de vídeo no aborta limpiamente si no hay driver, y los errores de renderizado pueden terminar el bucle principal. +6. **Pantalla info poco extensible**: `PluginInfo` está acoplado a una pantalla fija de texto; no existe un sistema genérico de páginas rotativas configurables. + +## Bugs identificados + +### 1. Iconos con rutas inexistentes + +**Zona:** `piweatherrock/plugin_weather_common/__init__.py`, método `icon_mapping()`. + +El método devuelve rutas que después se cargan directamente con: + +- `pygame.image.load(self.icon_mapping(data.icon, self.icon_size)).convert_alpha()` + +Problemas detectados: + +- `chainsleet` parece un typo de `chancesleet`. + - Open-Meteo mapea códigos 56 y 66 a `chainsleet`. + - `icon_mapping()` busca `icons/{size}/chainsleet.png`. + - En el repositorio existen `chancesleet.png`, no `chainsleet.png`. +- Algunos iconos se buscan en `icons/alt_icons/{size}/`, pero no existen allí: + - `chancesnow` + - `chanceflurries` + - `flurries` + - `chancetstorms` +- Si llega cualquier icono inesperado, el `else` sí cae a `icons/{size}/unknown.png`; el problema principal está en iconos “esperados” por el conversor pero mal enrutados. + +**Impacto:** fallo de renderizado y posible cierre de la aplicación cuando `pygame` no encuentra el fichero. + +**Recomendación:** + +- Normalizar nombres de iconos en un diccionario único. +- Validar la existencia del fichero antes de devolverlo. +- Si no existe, registrar warning y usar `unknown.png`. +- Añadir prueba unitaria o script de validación que recorra todos los valores emitidos por `get_darksky_icon()` y confirme que `icon_mapping()` resuelve a un fichero real para tamaños `64` y `256`. + +### 2. El fallo inicial de forecast puede dejar la aplicación en estado inválido + +**Zona:** `piweatherrock/weather.py`, `Weather.__init__()` y `get_forecast()`. + +`Weather.__init__()` llama a `self.get_forecast()` pero no comprueba su resultado. Además, `get_forecast()` asigna `last_update_check = time.time()` antes de completar correctamente la petición y parseo. + +Escenario problemático: + +1. Arranca la aplicación. +2. Falla la primera llamada a la API. +3. `self.weather` queda como `{}`. +4. `Runner.main()` vuelve a llamar a `get_forecast()`. +5. Como `last_update_check` ya se actualizó, puede devolver `True` sin reintentar. +6. Los plugins acceden a `self.weather.daily`, `self.weather.hourly`, etc., y pueden fallar. + +**Impacto:** errores intermitentes en arranque, especialmente en Raspberry Pi si la red todavía no está lista. + +**Recomendación:** + +- Actualizar `last_update_check` solo tras una descarga y parseo correctos. +- Si no hay forecast válido inicial, mostrar una pantalla de error/reintento en vez de entrar en la rotación normal. +- Mantener último forecast válido en memoria y, opcionalmente, persistirlo en disco como caché. + +### 3. `config.json-sample` no incluye `timezone` + +**Zona:** `piweatherrock/config.json-sample` y `piweatherrock/weather.py`. + +`Weather.get_forecast()` usa `self.config["timezone"]`, pero el fichero de ejemplo no define esa clave. + +**Impacto:** un usuario que parta del ejemplo puede obtener `KeyError: 'timezone'`. + +**Recomendación:** + +- Añadir `timezone` al ejemplo. +- Validar configuración al inicio con mensajes claros. +- Definir valores por defecto o una migración de configuración. + +### 4. La configuración `plugins.*.enabled` no se respeta + +**Zona:** `piweatherrock/runner.py`, `config.json-sample`, `piweatherrock/piweatherrock-config.json`. + +La configuración declara: + +- `plugins.daily.enabled` +- `plugins.hourly.enabled` + +Pero `Runner` instancia siempre `PluginWeatherDaily`, `PluginWeatherHourly` y `PluginInfo`, y la rotación alterna siempre entre diario, horario e info. + +Además, `piweatherrock/piweatherrock-config.json` marca `daily.enabled=false` y `hourly.enabled=false`, pero la aplicación seguiría intentando mostrarlas. + +**Impacto:** la configuración promete una funcionalidad que no existe; dificulta seleccionar páginas visibles. + +**Recomendación:** + +- Reemplazar la lógica fija `d/h/i` por una lista de páginas configuradas. +- Excluir páginas con `enabled=false`. +- Validar que exista al menos una página habilitada. +- Permitir orden, duración y parámetros por página. + +### 5. Riesgo de `IndexError` si Open-Meteo devuelve menos horas de las esperadas + +**Zona:** `piweatherrock/climate/openmeteo.py`, `plugin_weather_hourly/__init__.py`. + +`openmeteo_to_darksky()` filtra las próximas 4 horas. Luego `PluginWeatherHourly.disp_hourly()` asume que hay al menos 4 elementos (`hourly[0]` y tres futuros). + +**Impacto:** si por zona horaria, límite de datos, cambio de día o respuesta incompleta se obtienen menos de 4 registros, la pantalla horaria puede fallar. + +**Recomendación:** + +- Garantizar relleno mínimo en el adaptador o limitar el renderizado a `min(len(hourly), 4)`. +- Mostrar placeholders si faltan datos. + +### 6. Unidades declaradas no siempre coinciden con los datos solicitados + +**Zona:** `piweatherrock/climate/forecast.py` y `plugin_weather_common/__init__.py`. + +La URL de Open-Meteo solicita siempre: + +- `temperature_unit=celsius` +- `windspeed_unit=kmh` + +Pero la UI usa `config["units"]` para etiquetar grados y viento como si fueran `us`, `si`, `ca` o `uk2`. + +**Impacto:** riesgo de mostrar valores en una unidad y etiquetas en otra. + +**Recomendación:** + +- Traducir `config["units"]` a parámetros Open-Meteo reales. +- O restringir la configuración a unidades soportadas por la consulta actual. + +### 7. Uso de `eval()` en configuración y modelo de datos + +**Zonas:** + +- `piweatherrock/weather.py`, `get_logger()`. +- `piweatherrock/climate/data.py`, `DataPoint.__setattr__()`. + +`get_logger()` usa `eval(f"logging.{self.config['log_level']}")`. Aunque el valor venga de configuración local, no es necesario y puede sustituirse por `getattr(logging, level, logging.INFO)`. + +`DataPoint.__setattr__()` usa `eval(name.capitalize())` para `alerts` y `flags`; puede reemplazarse por un diccionario explícito. + +**Impacto:** superficie de riesgo innecesaria y errores más difíciles de diagnosticar. + +**Recomendación:** eliminar `eval()` en ambos puntos. + +## Mejoras de disponibilidad en Raspberry Pi + +### Inicialización de vídeo + +**Zona:** `piweatherrock/weather.py`. + +El código intenta inicializar drivers SDL (`x11`, `fbcon`, `directfb`, `svgalib`), pero si no encuentra ninguno solo registra una excepción y continúa. Después usa `pygame.display.Info()` y `set_mode()`, lo que puede fallar de forma menos controlada. + +**Recomendaciones:** + +- Si no hay driver, abortar con mensaje claro y código de salida controlado. +- Permitir configurar `SDL_VIDEODRIVER` desde configuración o entorno documentado. +- Añadir modo `windowed`/diagnóstico para pruebas por SSH o X11. +- Registrar driver elegido y resolución final. + +### Red y API + +**Zona:** `piweatherrock/climate/forecast.py`, `piweatherrock/weather.py`. + +Actualmente se prepara un `request_params` con timeout, pero no se usa en `requests.get(self.url)`. Por tanto, la petición puede bloquear más de lo deseado. + +**Recomendaciones:** + +- Usar `requests.get(self.url, timeout=...)`. +- Configurar timeout por defecto razonable. +- Añadir reintentos con backoff. +- No invalidar el último forecast correcto si falla una actualización. +- Mostrar pantalla “datos no disponibles” manteniendo la aplicación viva. + +### Robustez del bucle principal + +**Zona:** `piweatherrock/runner.py`. + +El bucle principal no protege cada renderizado. Un error en una pantalla concreta puede terminar toda la aplicación. + +**Recomendaciones:** + +- Capturar excepciones por página. +- Registrar el error, deshabilitar temporalmente la página problemática y continuar con la siguiente. +- Añadir watchdog externo con `systemd` (`Restart=always`) y logs persistentes. + +### Validación de configuración + +**Recomendaciones:** + +- Validar al inicio: + - claves obligatorias (`lat`, `lon`, `timezone`, `units`, `ui_lang`, `plugins`); + - tipos; + - rangos (`pause > 0`, `update_freq > 0`); + - al menos una página habilitada; + - rutas de imágenes existentes. +- Mostrar errores de configuración en pantalla cuando sea posible. + +### Caché local + +**Recomendación:** + +- Guardar el último forecast válido en un JSON local. +- En arranque sin red, cargar caché si no está demasiado caducada. +- Mostrar una marca visual de “datos desactualizados”. + +## Sustitución de la pantalla de información por marco de fotos + +### Situación actual + +`PluginInfo` renderiza una pantalla fija con hora, amanecer, atardecer, horas de luz y última actualización. `Runner` entra en esta pantalla cuando: + +- se pulsa `i`; +- se supera `info_delay` mientras se muestran pantallas meteorológicas; +- vuelve al tiempo tras `info_pause`. + +### Objetivo funcional + +Sustituir o complementar esa pantalla por una página tipo **marco de fotos** que muestre imágenes a pantalla completa mientras no se muestra el tiempo. + +### Propuesta de diseño + +Crear un nuevo plugin/página, por ejemplo `photo_frame`, con configuración propia: + +- `enabled`: activar/desactivar. +- `pause`: duración de cada imagen. +- `path`: directorio de imágenes. +- `shuffle`: orden aleatorio o secuencial. +- `fit`: estrategia de escalado (`contain`, `cover`, `stretch`). +- `background`: color para bandas si se usa `contain`. +- `show_clock`: opcional para superponer hora. +- `extensions`: lista permitida (`jpg`, `jpeg`, `png`, `webp` si pygame lo soporta). + +Comportamiento recomendado: + +- Escanear el directorio al arrancar y periódicamente. +- Ignorar ficheros corruptos registrando warning. +- Escalar la imagen a resolución completa sin deformar por defecto. +- Precargar la siguiente imagen para evitar parpadeos. +- Si no hay imágenes válidas, mostrar una pantalla de fallback. + +### Relación con la pantalla `info` + +Opciones: + +1. Mantener `info` como página independiente y añadir `photo_frame`. +2. Reemplazar `info` por `photo_frame`. +3. Permitir ambas y que el usuario configure cuáles rotan. + +La opción 3 es la más flexible y encaja con la necesidad de seleccionar páginas desde una aplicación de configuración. + +## Selección de páginas y configuración desde aplicación de configuración + +### Problema actual + +La rotación está codificada en `Runner` con estados fijos: + +- `d`: diario. +- `h`: horario. +- `i`: info. + +Esto impide añadir nuevas páginas o respetar completamente `enabled`. + +### Modelo de configuración propuesto + +Evolucionar a una configuración basada en lista ordenada de páginas: + +```json +{ + "pages": [ + { + "id": "daily", + "enabled": true, + "pause": 60 + }, + { + "id": "hourly", + "enabled": true, + "pause": 60 + }, + { + "id": "photo_frame", + "enabled": true, + "pause": 20, + "path": "/home/pi/Pictures", + "shuffle": true, + "fit": "cover" + }, + { + "id": "info", + "enabled": false, + "pause": 60 + } + ] +} +``` + +Por compatibilidad, se puede migrar desde `plugins.daily/hourly` a `pages`. + +### Cambios recomendados en arquitectura + +- Crear una interfaz común de página/plugin: + - `id` + - `render(weather_rock)` + - `on_enter()` opcional + - `on_exit()` opcional + - `requires_weather` +- Convertir `daily`, `hourly`, `info` y `photo_frame` a páginas registrables. +- Sustituir `d_count`, `h_count`, `current_screen` e `info_delay` por un planificador genérico. +- Añadir teclas para avanzar/retroceder página y no depender solo de `d`, `h`, `i`. + +### Aplicación de configuración + +El repositorio ya depende de `piweatherrock-webconfig==1.5.0`, por lo que conviene integrar la configuración de páginas en esa aplicación o evolucionarla. + +Campos mínimos que debería exponer: + +- Activar/desactivar páginas. +- Orden de páginas. +- Duración por página. +- Parámetros específicos de cada página. +- Validación de directorio de imágenes. +- Test de resolución y modo fullscreen. +- Validación de zona horaria, latitud, longitud e idioma. +- Botón para probar carga de iconos y forecast. + +## Priorización recomendada + +### Prioridad alta + +1. Corregir mapeos de iconos y fallback a `unknown.png`. +2. Arreglar reintento inicial de forecast y no actualizar `last_update_check` en fallo. +3. Añadir `timezone` a `config.json-sample`. +4. Usar timeout real en `requests.get()`. +5. Respetar `enabled` o eliminarlo hasta implementarlo correctamente. + +### Prioridad media + +1. Validación formal de configuración. +2. Caché local del último forecast válido. +3. Manejo de excepciones por página. +4. Alinear unidades Open-Meteo con `config["units"]`. +5. Eliminar `eval()`. + +### Prioridad baja / evolutiva + +1. Nuevo sistema genérico de páginas. +2. Plugin `photo_frame`. +3. Configuración visual de páginas en `piweatherrock-webconfig`. +4. Previsualización de páginas desde la aplicación de configuración. + +## Plan incremental sugerido + +1. **Estabilización** + - Corregir iconos. + - Añadir fallback seguro. + - Añadir timeout y reintento básico. + - Validar configuración mínima. + +2. **Configurabilidad real** + - Respetar `enabled`. + - Migrar rotación fija a lista de páginas. + - Mantener compatibilidad con configuración existente. + +3. **Marco de fotos** + - Crear página `photo_frame`. + - Añadir carga segura de imágenes. + - Añadir opciones de escalado y orden. + +4. **Aplicación de configuración** + - Exponer selección y orden de páginas. + - Exponer parámetros de `photo_frame`. + - Añadir validación y pruebas desde UI. + +5. **Disponibilidad Raspberry Pi** + - Documentar servicio `systemd`. + - Añadir caché de forecast. + - Añadir modo diagnóstico de vídeo/red. + +## Validaciones recomendadas + +- Test automático de que todos los iconos emitidos por `get_darksky_icon()` existen para `64` y `256`. +- Test de arranque con forecast fallido. +- Test de configuración sin `timezone`. +- Test con `daily.enabled=false` y/o `hourly.enabled=false`. +- Test con menos de 4 horas disponibles. +- Test manual en Raspberry Pi: + - arranque sin red; + - arranque con red lenta; + - pantalla HDMI conectada tarde; + - ejecución como servicio `systemd`; + - directorio de fotos vacío, inexistente y con imágenes corruptas. + From 3ee72ff132a4fa5fa7ad93009ed1572998c44bda Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 18:58:46 +0000 Subject: [PATCH 41/91] Clarify icon typo audit finding Agent-Logs-Url: https://github.com/carloshm/PiWeatherRock/sessions/346f40e5-72fb-4dff-be08-73ceae6ac4bd Co-authored-by: carloshm <2389573+carloshm@users.noreply.github.com> --- AUDITORIA_PROYECTO.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/AUDITORIA_PROYECTO.md b/AUDITORIA_PROYECTO.md index 0bacb6d..c0dafe2 100644 --- a/AUDITORIA_PROYECTO.md +++ b/AUDITORIA_PROYECTO.md @@ -34,9 +34,9 @@ El método devuelve rutas que después se cargan directamente con: Problemas detectados: -- `chainsleet` parece un typo de `chancesleet`. - - Open-Meteo mapea códigos 56 y 66 a `chainsleet`. - - `icon_mapping()` busca `icons/{size}/chainsleet.png`. +- La cadena literal `chainsleet` usada en el código parece un typo de `chancesleet`. + - Open-Meteo mapea códigos 56 y 66 a la cadena literal `chainsleet`. + - `icon_mapping()` busca el fichero literal `icons/{size}/chainsleet.png`. - En el repositorio existen `chancesleet.png`, no `chainsleet.png`. - Algunos iconos se buscan en `icons/alt_icons/{size}/`, pero no existen allí: - `chancesnow` @@ -406,4 +406,3 @@ Campos mínimos que debería exponer: - pantalla HDMI conectada tarde; - ejecución como servicio `systemd`; - directorio de fotos vacío, inexistente y con imágenes corruptas. - From 44c89fac71884140734e16d18ddd34c5a92248a8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 18:59:19 +0000 Subject: [PATCH 42/91] Emphasize eval risk in audit Agent-Logs-Url: https://github.com/carloshm/PiWeatherRock/sessions/346f40e5-72fb-4dff-be08-73ceae6ac4bd Co-authored-by: carloshm <2389573+carloshm@users.noreply.github.com> --- AUDITORIA_PROYECTO.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AUDITORIA_PROYECTO.md b/AUDITORIA_PROYECTO.md index c0dafe2..07107a0 100644 --- a/AUDITORIA_PROYECTO.md +++ b/AUDITORIA_PROYECTO.md @@ -34,7 +34,7 @@ El método devuelve rutas que después se cargan directamente con: Problemas detectados: -- La cadena literal `chainsleet` usada en el código parece un typo de `chancesleet`. +- La cadena literal `chainsleet` usada en el código no coincide con los ficheros `chancesleet.png` incluidos en el repositorio. - Open-Meteo mapea códigos 56 y 66 a la cadena literal `chainsleet`. - `icon_mapping()` busca el fichero literal `icons/{size}/chainsleet.png`. - En el repositorio existen `chancesleet.png`, no `chainsleet.png`. @@ -155,7 +155,7 @@ Pero la UI usa `config["units"]` para etiquetar grados y viento como si fueran ` `DataPoint.__setattr__()` usa `eval(name.capitalize())` para `alerts` y `flags`; puede reemplazarse por un diccionario explícito. -**Impacto:** superficie de riesgo innecesaria y errores más difíciles de diagnosticar. +**Impacto:** superficie de riesgo innecesaria y errores más difíciles de diagnosticar. En el caso de `log_level`, si en el futuro la configuración pasa a ser editable desde una interfaz web o cualquier entrada controlable por usuario, el `eval()` sobre ese valor podría convertirse en una vulnerabilidad crítica de inyección de código. **Recomendación:** eliminar `eval()` en ambos puntos. From f5c6b65c4cf36ca9540142944b0c62e623284e32 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 12:17:36 +0000 Subject: [PATCH 43/91] refactor: migrate from setup.py to pyproject.toml (PEP 621) - Replace setup.py with pyproject.toml using PEP 621 metadata - Move script entry points from scripts/ to console_scripts in pyproject.toml - Create piweatherrock/pwr_ui.py and piweatherrock/pwr_config_upgrade.py modules - Remove legacy files: setup.py, MANIFEST.in, version.py, scripts/ - Package data now configured via [tool.setuptools.package-data] Agent-Logs-Url: https://github.com/carloshm/PiWeatherRock/sessions/ca7869ce-ea11-4d93-99d7-87f66d6d793c Co-authored-by: carloshm <2389573+carloshm@users.noreply.github.com> --- MANIFEST.in | 2 - .../pwr_config_upgrade.py | 0 scripts/pwr-ui => piweatherrock/pwr_ui.py | 0 pyproject.toml | 44 +++++++++++++++++++ setup.py | 44 ------------------- version.py | 1 - 6 files changed, 44 insertions(+), 47 deletions(-) delete mode 100644 MANIFEST.in rename scripts/pwr-config-upgrade => piweatherrock/pwr_config_upgrade.py (100%) rename scripts/pwr-ui => piweatherrock/pwr_ui.py (100%) create mode 100644 pyproject.toml delete mode 100644 setup.py delete mode 100644 version.py diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 643baf9..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,2 +0,0 @@ -include piweatherrock/config.json-sample -include piweatherrock/plugin_weather_common/icons/**/* diff --git a/scripts/pwr-config-upgrade b/piweatherrock/pwr_config_upgrade.py similarity index 100% rename from scripts/pwr-config-upgrade rename to piweatherrock/pwr_config_upgrade.py diff --git a/scripts/pwr-ui b/piweatherrock/pwr_ui.py similarity index 100% rename from scripts/pwr-ui rename to piweatherrock/pwr_ui.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7953e6c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,44 @@ +[build-system] +requires = ["setuptools>=68.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "piweatherrock" +version = "2.2.1" +description = "Provides forecast data from ClimaCell for PiWeatherRock" +readme = "README.md" +license = {text = "MIT"} +requires-python = ">=3.7" +authors = [ + { name = "Gene Liverman", email = "gene@technicalissues.us" }, +] +classifiers = [ + "License :: OSI Approved :: MIT License", +] +dependencies = [ + "pygame", + "pyserial", + "requests", + "cherrypy", + "babel", + "python-i18n", + "pytz", + "piweatherrock-webconfig==1.5.0", +] + +[project.urls] +Homepage = "https://piweatherrock.technicalissues.us" + +[project.scripts] +pwr-ui = "piweatherrock.pwr_ui:main" +pwr-config-upgrade = "piweatherrock.pwr_config_upgrade:main" + +[tool.setuptools.packages.find] +include = ["piweatherrock*"] + +[tool.setuptools.package-data] +piweatherrock = [ + "config.json-sample", + "data/*.json", + "plugin_weather_common/icons/**/*", +] diff --git a/setup.py b/setup.py deleted file mode 100644 index d899840..0000000 --- a/setup.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Module setup.""" - -import runpy -from setuptools import setup, find_namespace_packages, find_packages - -PACKAGE_NAME = "piweatherrock" -version_meta = runpy.run_path("./version.py") -VERSION = version_meta["__version__"] - - -with open("README.md", "r") as fh: - long_description = fh.read() - - -def parse_requirements(filename): - """Load requirements from a pip requirements file.""" - lineiter = (line.strip() for line in open(filename)) - return [line for line in lineiter if line and not line.startswith("#")] - - -if __name__ == "__main__": - setup( - name=PACKAGE_NAME, - author="Gene Liverman", - author_email="gene@technicalissues.us", - version=VERSION, - packages=find_packages(), - include_package_data=True, - package_data={'':['data/*.json']}, - install_requires=parse_requirements("requirements.txt"), - python_requires=">=3.6", - scripts=[ - 'scripts/pwr-ui', - 'scripts/pwr-config-upgrade', - ], - description="Provides forecast data from ClimaCell for PiWeatherRock", - long_description=long_description, - long_description_content_type="text/markdown", - license='MIT', - url='https://piweatherrock.technicalissues.us', - classifiers=[ - 'License :: OSI Approved :: MIT License', - ], - ) diff --git a/version.py b/version.py deleted file mode 100644 index 36a511e..0000000 --- a/version.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = '2.2.1' From 0791ea48719530f6ce7f9b39fd9d9053321cd1b8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 12:18:29 +0000 Subject: [PATCH 44/91] fix: restore requires-python to >=3.6 to match original setup.py Agent-Logs-Url: https://github.com/carloshm/PiWeatherRock/sessions/ca7869ce-ea11-4d93-99d7-87f66d6d793c Co-authored-by: carloshm <2389573+carloshm@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 7953e6c..2ec4da3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ version = "2.2.1" description = "Provides forecast data from ClimaCell for PiWeatherRock" readme = "README.md" license = {text = "MIT"} -requires-python = ">=3.7" +requires-python = ">=3.6" authors = [ { name = "Gene Liverman", email = "gene@technicalissues.us" }, ] From d10e1b1ee89a52462ac017e7b08007774c0d851c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 12:34:27 +0000 Subject: [PATCH 45/91] docs: update documentation and metadata for new deployment - Update README.md: fix badges, remove old doc site, update release process to use pyproject.toml/python -m build, fix run instructions to use entry points, use bash code blocks - Update pyproject.toml: bump version to 3.0.0, fix description from ClimaCell to Open-Meteo, add Carlos HM as author, update homepage - Update config.json-sample: add timezone, update version and defaults - Update CHANGELOG.md: add packaging and metadata migration notes - Replace install.sh: modernize from Puppet to pip/venv - Remove .travis.yml: obsolete CI config no longer in use Agent-Logs-Url: https://github.com/carloshm/PiWeatherRock/sessions/2a169c74-bca0-4d57-bf32-26d1e6444c08 Co-authored-by: carloshm <2389573+carloshm@users.noreply.github.com> --- .travis.yml | 9 --------- CHANGELOG.md | 3 +++ README.md | 30 +++++++++++++++--------------- install.sh | 32 +++++++++++++++++++++----------- piweatherrock/config.json-sample | 7 ++++--- pyproject.toml | 7 ++++--- 6 files changed, 47 insertions(+), 41 deletions(-) delete mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index e1fc465..0000000 --- a/.travis.yml +++ /dev/null @@ -1,9 +0,0 @@ -language: python -python: - - '3.7' -install: - - pip install -r requirements.txt - - pip install pylint -script: - - pylint piweatherrock - diff --git a/CHANGELOG.md b/CHANGELOG.md index bf1919d..0b2879d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ - No API key is required for non-commercial use with Open-Meteo - Added internationalization support with `intl` module - Updated configuration to use Open-Meteo endpoint +- Migrated packaging from `setup.py` to `pyproject.toml` (PEP 621) +- Added `timezone` to configuration +- Updated project metadata and homepage to carloshm fork ## [2.1.0](https://github.com/genebean/PiWeatherRock/tree/2.1.0) diff --git a/README.md b/README.md index bfd2d15..38d6d96 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,9 @@ # PiWeatherRock -![GitHub](https://img.shields.io/github/license/genebean/PiWeatherRock) -![PyPI](https://img.shields.io/pypi/v/piweatherrock) +![GitHub](https://img.shields.io/github/license/carloshm/PiWeatherRock) PiWeatherRock displays local weather on (almost) any screen you connect to a Raspberry Pi. It also works on other platforms, including macOS. -More information about the project and full documentation can be found at https://piweatherrock.technicalissues.us. Be sure to check out the getting started guide under the documentation link there for instruction on how to set everything up. - ## Weather API This project uses the [Open-Meteo API](https://open-meteo.com/) to fetch weather data. Open-Meteo is a free, open-source weather API that does not require an API key for non-commercial use. @@ -26,9 +23,10 @@ Weather settings are configured in `piweatherrock/piweatherrock-config.json`: ## Release process -- edit `version.py` according to the types of changes made -- edit `requirements.txt` if needed -- `python3 setup.py sdist bdist_wheel` +- Update version in `pyproject.toml` according to the types of changes made +- Update `requirements.txt` if needed +- `python3 -m pip install --upgrade build twine` +- `python3 -m build` - `tar tzf dist/piweatherrock-*.tar.gz` - `twine check dist/*` - [optional] `twine upload --repository-url https://test.pypi.org/legacy/ dist/*` @@ -37,33 +35,35 @@ Weather settings are configured in `piweatherrock/piweatherrock-config.json`: ## Local Development process -```python +```bash python3 -m venv env_name source env_name/bin/activate ``` -```python +```bash git clone https://github.com/carloshm/PiWeatherRock.git -cd PiWeatherRock -git pull (for any additional external change after a while) +cd PiWeatherRock +git pull # for any additional external change after a while ``` Make changes -```python +```bash git add . git commit -m "changes description" git push origin main ``` -## Run changes https://blog.ganssle.io/articles/2021/10/setup-py-deprecated.html +## Run changes -```python +```bash python3 -m pip install --upgrade setuptools wheel python3 -m pip install . -python3 ./scripts/pwr-ui -c ./piweatherrock/piweatherrock-config.json +pwr-ui -c ./piweatherrock/piweatherrock-config.json ``` +> **Note:** `pwr-ui` and `pwr-config-upgrade` are installed as console entry points via `pyproject.toml`. See [PEP 621](https://peps.python.org/pep-0621/) and [setup.py deprecation](https://blog.ganssle.io/articles/2021/10/setup-py-deprecated.html) for background. + ## Validate Service Data You can test the Open-Meteo API directly with a request like this: diff --git a/install.sh b/install.sh index 98b3ed2..038d6bc 100644 --- a/install.sh +++ b/install.sh @@ -1,19 +1,29 @@ #!/usr/bin/env bash +# Install PiWeatherRock on a Raspberry Pi (or similar Linux system). +# Usage: ./install.sh [timezone] +# Example: ./install.sh Europe/Madrid -NAME=$1 +set -euo pipefail -# set the timezone -sudo timedatectl set-timezone America/New_York +TIMEZONE="${1:-UTC}" -# update the hostname -sudo hostnamectl set-hostname $NAME -sudo sed -i "s|raspberrypi|${NAME}|g" /etc/hosts +echo "==> Setting timezone to ${TIMEZONE}..." +sudo timedatectl set-timezone "${TIMEZONE}" -# patch the system and do setup +echo "==> Updating system packages..." sudo apt update sudo apt full-upgrade -y -sudo apt install -y git puppet -sudo rm -f /etc/puppet/hiera.yaml -sudo puppet module install genebean-piweatherrock +sudo apt install -y python3 python3-pip python3-venv git libsdl2-dev libsdl2-image-dev libsdl2-mixer-dev libsdl2-ttf-dev -sudo puppet apply -e 'include piweatherrock' +echo "==> Creating virtual environment..." +python3 -m venv ~/pwr-env +source ~/pwr-env/bin/activate + +echo "==> Installing PiWeatherRock..." +pip install --upgrade pip setuptools wheel +pip install . + +echo "" +echo "Installation complete." +echo "Activate the environment with: source ~/pwr-env/bin/activate" +echo "Run with: pwr-ui -c ./piweatherrock/piweatherrock-config.json" diff --git a/piweatherrock/config.json-sample b/piweatherrock/config.json-sample index 468548e..0e9b79c 100644 --- a/piweatherrock/config.json-sample +++ b/piweatherrock/config.json-sample @@ -1,11 +1,12 @@ { - "version": "1.4.0", - "ds_api_key": "API_KEY_HERE", + "version": "3.0.0", + "ds_api_key": "openmeteo-request-piweatherrock", "lat": 0.112358, "lon": 0.246810, - "units": "us", + "units": "si", "lang": "en", "ui_lang": "en", + "timezone": "Europe/London", "fullscreen": true, "icon_offset": -23.5, "update_freq": 300, diff --git a/pyproject.toml b/pyproject.toml index 2ec4da3..fe930a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,13 +4,14 @@ build-backend = "setuptools.build_meta" [project] name = "piweatherrock" -version = "2.2.1" -description = "Provides forecast data from ClimaCell for PiWeatherRock" +version = "3.0.0" +description = "Displays local weather on a Raspberry Pi using the Open-Meteo API" readme = "README.md" license = {text = "MIT"} requires-python = ">=3.6" authors = [ { name = "Gene Liverman", email = "gene@technicalissues.us" }, + { name = "Carlos HM" }, ] classifiers = [ "License :: OSI Approved :: MIT License", @@ -27,7 +28,7 @@ dependencies = [ ] [project.urls] -Homepage = "https://piweatherrock.technicalissues.us" +Homepage = "https://github.com/carloshm/PiWeatherRock" [project.scripts] pwr-ui = "piweatherrock.pwr_ui:main" From 4cbbd12089a77d0efc5a2de57d6114b64b2c7552 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 12:35:10 +0000 Subject: [PATCH 46/91] docs: add config file setup instructions in README and install.sh Agent-Logs-Url: https://github.com/carloshm/PiWeatherRock/sessions/2a169c74-bca0-4d57-bf32-26d1e6444c08 Co-authored-by: carloshm <2389573+carloshm@users.noreply.github.com> --- README.md | 9 +++++++++ install.sh | 5 +++++ 2 files changed, 14 insertions(+) diff --git a/README.md b/README.md index 38d6d96..590b042 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,15 @@ git push origin main ## Run changes +Before running for the first time, create your configuration file from the sample: + +```bash +cp piweatherrock/config.json-sample piweatherrock/piweatherrock-config.json +# Edit piweatherrock/piweatherrock-config.json with your coordinates, timezone, etc. +``` + +Then install and run: + ```bash python3 -m pip install --upgrade setuptools wheel python3 -m pip install . diff --git a/install.sh b/install.sh index 038d6bc..dd68010 100644 --- a/install.sh +++ b/install.sh @@ -26,4 +26,9 @@ pip install . echo "" echo "Installation complete." echo "Activate the environment with: source ~/pwr-env/bin/activate" +echo "" +echo "Before running, create your config file:" +echo " cp piweatherrock/config.json-sample piweatherrock/piweatherrock-config.json" +echo " # Edit piweatherrock-config.json with your coordinates, timezone, etc." +echo "" echo "Run with: pwr-ui -c ./piweatherrock/piweatherrock-config.json" From 3963070f03667606cba66af6e54c11edff2b7acc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 12:41:59 +0000 Subject: [PATCH 47/91] fix: address audit bugs and performance issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix icon typo chainsleet → chancesleet in openmeteo.py (Bug #1) - Fix icon paths: chancesnow, chanceflurries, flurries, chancetstorms now point to icons/ instead of icons/alt_icons/ (Bug #1) - Add file existence check + fallback to unknown.png in icon_mapping() (Bug #1) - Move last_update_check to after successful fetch (Bug #2) - Guard against IndexError when fewer than 4 hourly entries (Bug #5) - Guard against empty filtered_hourly_data in openmeteo.py (Bug #5) - Replace eval() with getattr() in weather.py get_logger() (Bug #7) - Replace eval() with dict lookup in data.py (Bug #7) - Pass timeout (default 30s) to requests.get() in forecast.py - Abort cleanly with sys.exit(1) if no video driver found - Add try/except per page render in runner.py main loop Agent-Logs-Url: https://github.com/carloshm/PiWeatherRock/sessions/f565b331-7285-4e93-a420-6a15a192bc3c Co-authored-by: carloshm <2389573+carloshm@users.noreply.github.com> --- piweatherrock/climate/data.py | 8 ++- piweatherrock/climate/forecast.py | 10 ++- piweatherrock/climate/openmeteo.py | 40 +++++++---- .../plugin_weather_common/__init__.py | 72 +++++++++---------- .../plugin_weather_hourly/__init__.py | 10 ++- piweatherrock/runner.py | 18 ++++- piweatherrock/weather.py | 11 +-- 7 files changed, 100 insertions(+), 69 deletions(-) diff --git a/piweatherrock/climate/data.py b/piweatherrock/climate/data.py index e064c3c..ec4bc35 100644 --- a/piweatherrock/climate/data.py +++ b/piweatherrock/climate/data.py @@ -21,8 +21,12 @@ def setval(new_val=None): return setval() # set specific data handlers - if name in ('alerts', 'flags'): - return setval(eval(name.capitalize())(val)) + _handlers = { + 'alerts': Alerts, + 'flags': Flags, + } + if name in _handlers: + return setval(_handlers[name](val)) # data if isinstance(val, list): diff --git a/piweatherrock/climate/forecast.py b/piweatherrock/climate/forecast.py index e455994..6e7d795 100644 --- a/piweatherrock/climate/forecast.py +++ b/piweatherrock/climate/forecast.py @@ -86,11 +86,6 @@ def url(self): def refresh(self, timeout=None, **queries): self._queries = queries self.timeout = timeout - request_params = { - 'params': self._queries, - 'headers': {'Accept-Encoding': 'gzip'}, - 'timeout': timeout - } if _LOAD_FROM_FILE_: file_path = path.join(path.dirname(__file__),'data','example.json') @@ -98,7 +93,10 @@ def refresh(self, timeout=None, **queries): return super().__init__(data) else: - response = requests.get(self.url) + response = requests.get( + self.url, + headers={'Accept-Encoding': 'gzip'}, + timeout=timeout if timeout is not None else 30) self.response_headers = response.headers if response.status_code != 200: print(response.text) diff --git a/piweatherrock/climate/openmeteo.py b/piweatherrock/climate/openmeteo.py index f652f8d..6165dc0 100644 --- a/piweatherrock/climate/openmeteo.py +++ b/piweatherrock/climate/openmeteo.py @@ -49,12 +49,12 @@ def get_darksky_icon(wmocode): 51: 'chancerain', 53: 'rain', 55: 'rain', - 56: 'chainsleet', + 56: 'chancesleet', 57: 'sleet', 61: 'chancerain', 63: 'rain', 65: 'rain', - 66: 'chainsleet', + 66: 'chancesleet', 67: 'sleet', 71: 'chancesnow', 73: 'chancesnow', @@ -145,8 +145,20 @@ def openmeteo_to_darksky(data, lang): "ozone": 0, } darksky_data["hourly"]["data"].append(darksky_hour_data) - darksky_data["hourly"]["summary"] = get_weather_translations(lang, filtered_hourly_data["weathercode"][0]) - darksky_data["hourly"]["icon"] = get_darksky_icon(filtered_hourly_data["weathercode"][0]) + + if filtered_num_hours > 0: + darksky_data["hourly"]["summary"] = get_weather_translations(lang, filtered_hourly_data["weathercode"][0]) + darksky_data["hourly"]["icon"] = get_darksky_icon(filtered_hourly_data["weathercode"][0]) + else: + darksky_data["hourly"]["summary"] = "" + darksky_data["hourly"]["icon"] = "unknown" + + # Safe defaults from hourly data (used by daily and currently sections) + _hourly_dewpoint = filtered_hourly_data["dewpoint_2m"][0] if filtered_num_hours > 0 else 0 + _hourly_humidity = filtered_hourly_data["relativehumidity_2m"][0] / 100 if filtered_num_hours > 0 else 0 + _hourly_pressure = filtered_hourly_data["surface_pressure"][0] if filtered_num_hours > 0 else 0 + _hourly_cloudcover = filtered_hourly_data["cloudcover_low"][0] if filtered_num_hours > 0 else 0 + _hourly_visibility = filtered_hourly_data["visibility"][0] if filtered_num_hours > 0 else 0 # Daily weather data darksky_data["daily"] = { @@ -187,17 +199,17 @@ def openmeteo_to_darksky(data, lang): "apparentTemperatureHighTime": 0, "apparentTemperatureLow": daily_data["apparent_temperature_min"][i], "apparentTemperatureLowTime": 0, - "dewPoint": filtered_hourly_data["dewpoint_2m"][0], - "humidity": filtered_hourly_data["relativehumidity_2m"][0] / 100, - "pressure": filtered_hourly_data["surface_pressure"][0], + "dewPoint": _hourly_dewpoint, + "humidity": _hourly_humidity, + "pressure": _hourly_pressure, "windSpeed": daily_data["windspeed_10m_max"][i], "windGust": daily_data["windgusts_10m_max"][i], "windGustTime": 0, "windBearing": daily_data["winddirection_10m_dominant"][i], - "cloudCover": filtered_hourly_data["cloudcover_low"][0], + "cloudCover": _hourly_cloudcover, "uvIndex": daily_data["uv_index_max"][i], "uvIndexTime": 0, - "visibility": filtered_hourly_data["visibility"][0], + "visibility": _hourly_visibility, "ozone": 0, "temperatureMin": daily_data["temperature_2m_min"][i], "temperatureMinTime": 0, @@ -221,15 +233,15 @@ def openmeteo_to_darksky(data, lang): "precipType": "rain", "temperature": json_data["current_weather"]["temperature"], "apparentTemperature": json_data["current_weather"]["temperature"], - "dewPoint": filtered_hourly_data["dewpoint_2m"][0], - "humidity": filtered_hourly_data["relativehumidity_2m"][0] / 100, - "pressure": filtered_hourly_data["surface_pressure"][0], + "dewPoint": _hourly_dewpoint, + "humidity": _hourly_humidity, + "pressure": _hourly_pressure, "windSpeed": json_data["current_weather"]["windspeed"], "windGust": daily_data["windgusts_10m_max"][0], "windBearing": json_data["current_weather"]["winddirection"], - "cloudCover": filtered_hourly_data["cloudcover_low"][0], + "cloudCover": _hourly_cloudcover, "uvIndex": daily_data["uv_index_max"][0], - "visibility": filtered_hourly_data["visibility"][0], + "visibility": _hourly_visibility, "ozone": 0 } diff --git a/piweatherrock/plugin_weather_common/__init__.py b/piweatherrock/plugin_weather_common/__init__.py index e895b2f..22992c4 100644 --- a/piweatherrock/plugin_weather_common/__init__.py +++ b/piweatherrock/plugin_weather_common/__init__.py @@ -3,6 +3,7 @@ # Copyright (c) 2017 Gene Liverman # Distributed under the MIT License (https://opensource.org/licenses/MIT) +import logging import pygame import time @@ -456,43 +457,38 @@ def icon_mapping(self, icon, size): The Open-Meteo WMO codes are translated to these values in openmeteo.py. Based on that, this method will map the Dark Sky icon name to the name - of an icon in this project. + of an icon in this project. If the resolved file does not exist, falls + back to unknown.png to prevent crashes. """ - if icon == 'clear': - icon_path = 'icons/{}/clear.png'.format(size) - elif icon == 'mostlysunny': - icon_path = 'icons/{}/mostlysunny.png'.format(size) - elif icon == 'partlycloudy': - icon_path = 'icons/{}/partlycloudy.png'.format(size) - elif icon == 'cloudy': - icon_path = 'icons/{}/cloudy.png'.format(size) - elif icon == 'fog': - icon_path = 'icons/{}/fog.png'.format(size) - elif icon == 'hazy': - icon_path = 'icons/{}/hazy.png'.format(size) - elif icon == 'rain': - icon_path = 'icons/{}/rain.png'.format(size) - elif icon == 'chancerain': - icon_path = 'icons/{}/chancerain.png'.format(size) - elif icon == 'chainsleet': - icon_path = 'icons/{}/chainsleet.png'.format(size) - elif icon == 'snow': - icon_path = 'icons/{}/snow.png'.format(size) - elif icon == 'sleet': - icon_path = 'icons/{}/sleet.png'.format(size) - elif icon == 'wind': - icon_path = 'icons/alt_icons/{}/wind.png'.format(size) - elif icon == 'chancesnow': - icon_path = 'icons/alt_icons/{}/chancesnow.png'.format(size) - elif icon == 'tstorms' or icon == 'tstorm': - icon_path = 'icons/alt_icons/{}/tstorm.png'.format(size) - elif icon == 'chanceflurries': - icon_path = 'icons/alt_icons/{}/chanceflurries.png'.format(size) - elif icon == 'flurries': - icon_path = 'icons/alt_icons/{}/flurries.png'.format(size) - elif icon == 'chancetstorms': - icon_path = 'icons/alt_icons/{}/chancetstorms.png'.format(size) - else: - icon_path = 'icons/{}/unknown.png'.format(size) + icon_map = { + 'clear': 'icons/{}/clear.png', + 'mostlysunny': 'icons/{}/mostlysunny.png', + 'partlycloudy': 'icons/{}/partlycloudy.png', + 'cloudy': 'icons/{}/cloudy.png', + 'fog': 'icons/{}/fog.png', + 'hazy': 'icons/{}/hazy.png', + 'rain': 'icons/{}/rain.png', + 'chancerain': 'icons/{}/chancerain.png', + 'chancesleet': 'icons/{}/chancesleet.png', + 'snow': 'icons/{}/snow.png', + 'sleet': 'icons/{}/sleet.png', + 'wind': 'icons/alt_icons/{}/wind.png', + 'chancesnow': 'icons/{}/chancesnow.png', + 'tstorms': 'icons/{}/tstorms.png', + 'tstorm': 'icons/{}/tstorm.png', + 'chanceflurries': 'icons/{}/chanceflurries.png', + 'flurries': 'icons/{}/flurries.png', + 'chancetstorms': 'icons/{}/chancetstorms.png', + } + + base_dir = path.dirname(__file__) + icon_template = icon_map.get(icon, 'icons/{}/unknown.png') + icon_path = path.join(base_dir, icon_template.format(size)) + + if not path.isfile(icon_path): + logging.warning( + "Icon file not found: %s (icon=%s). Falling back to unknown.png.", + icon_path, icon) + icon_path = path.join(base_dir, 'icons/{}/unknown.png'.format(size)) - return path.join(path.dirname(__file__), icon_path) + return icon_path diff --git a/piweatherrock/plugin_weather_hourly/__init__.py b/piweatherrock/plugin_weather_hourly/__init__.py index b1cd793..b0f85e0 100644 --- a/piweatherrock/plugin_weather_hourly/__init__.py +++ b/piweatherrock/plugin_weather_hourly/__init__.py @@ -32,6 +32,12 @@ def disp_hourly(self, weather_rock): self.weather_common.disp_weather_top(weather_rock) + num_hours = len(self.weather.hourly) + if num_hours == 0: + # No hourly data available; skip rendering subwindows + pygame.display.update() + return + # Current hour this_hour = self.weather.hourly[0] this_hour_24_int = int(datetime.datetime.fromtimestamp( @@ -50,8 +56,8 @@ def disp_hourly(self, weather_rock): self.weather_common.display_subwindow( this_hour, this_hour_string, multiplier) - # counts from 0 to 2 - for future_hour in range(3): + # counts from 0 to 2, but only if we have enough data + for future_hour in range(min(3, num_hours - 1)): this_hour = self.weather.hourly[future_hour + 1] this_hour_24_int = int(datetime.datetime.fromtimestamp( this_hour.time).strftime("%H")) diff --git a/piweatherrock/runner.py b/piweatherrock/runner.py index 0869a11..fec1388 100644 --- a/piweatherrock/runner.py +++ b/piweatherrock/runner.py @@ -179,7 +179,11 @@ def screen_switcher(self): # Update / Refresh the display after each second. if self.seconds != time.localtime().tm_sec: self.seconds = time.localtime().tm_sec - self.daily.disp_daily(self.my_weather_rock) + try: + self.daily.disp_daily(self.my_weather_rock) + except Exception: + self.my_weather_rock.log.exception( + "Error rendering daily screen") # Once the screen is updated, we have a full second to get the # weather. Once per minute, check to see if its time to get a @@ -192,7 +196,11 @@ def screen_switcher(self): # Update / Refresh the display after each second. if self.seconds != time.localtime().tm_sec: self.seconds = time.localtime().tm_sec - self.hourly.disp_hourly(self.my_weather_rock) + try: + self.hourly.disp_hourly(self.my_weather_rock) + except Exception: + self.my_weather_rock.log.exception( + "Error rendering hourly screen") # Once the screen is updated, we have a full second to get the # weather. Once per minute, check to see if its time to get a @@ -208,7 +216,11 @@ def screen_switcher(self): # Disaplay information about the application along with the # time of sunrise and sunset. - self.info.disp_info(self.my_weather_rock) + try: + self.info.disp_info(self.my_weather_rock) + except Exception: + self.my_weather_rock.log.exception( + "Error rendering info screen") def check_forecast(self): try: diff --git a/piweatherrock/weather.py b/piweatherrock/weather.py index d5cb3cc..b05e0e3 100644 --- a/piweatherrock/weather.py +++ b/piweatherrock/weather.py @@ -81,7 +81,8 @@ def __init__(self, config_file): break if not found: - self.log.exception("No suitable video driver found!") + self.log.critical("No suitable video driver found!") + sys.exit(1) size = (pygame.display.Info().current_w, pygame.display.Info().current_h) @@ -133,9 +134,9 @@ def get_logger(self): verbosity of the logs is determined by the 'log_level' setting in the config file. """ - lvl_str = f"logging.{self.config['log_level']}" + log_level = getattr(logging, self.config['log_level'].upper(), logging.INFO) log = logging.getLogger() - log.setLevel(eval(lvl_str)) + log.setLevel(log_level) formatter = logging.Formatter( "%(asctime)s %(levelname)-8s %(message)s", datefmt='%Y-%m-%d %H:%M:%S') @@ -154,7 +155,6 @@ def get_forecast(self): passed since last querying the api. """ if (time.time() - self.last_update_check) > self.config["update_freq"]: - self.last_update_check = time.time() try: self.weather = forecast( self.config["ds_api_key"], @@ -190,6 +190,9 @@ def get_forecast(self): self.sunset_string = datetime.datetime.fromtimestamp( self.sunset).strftime("%H:%M {}").format(ss_suffix) + # Only update the check time after a successful fetch + self.last_update_check = time.time() + except requests.exceptions.RequestException as e: self.log.exception(f"Request exception: {e}") return False From 1308748e3212ee489059e5d8cd95c6b1bf58b14d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 12:43:18 +0000 Subject: [PATCH 48/91] fix: rename hourly helper variables to remove leading underscores Agent-Logs-Url: https://github.com/carloshm/PiWeatherRock/sessions/f565b331-7285-4e93-a420-6a15a192bc3c Co-authored-by: carloshm <2389573+carloshm@users.noreply.github.com> --- piweatherrock/climate/openmeteo.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/piweatherrock/climate/openmeteo.py b/piweatherrock/climate/openmeteo.py index 6165dc0..9879afb 100644 --- a/piweatherrock/climate/openmeteo.py +++ b/piweatherrock/climate/openmeteo.py @@ -154,11 +154,11 @@ def openmeteo_to_darksky(data, lang): darksky_data["hourly"]["icon"] = "unknown" # Safe defaults from hourly data (used by daily and currently sections) - _hourly_dewpoint = filtered_hourly_data["dewpoint_2m"][0] if filtered_num_hours > 0 else 0 - _hourly_humidity = filtered_hourly_data["relativehumidity_2m"][0] / 100 if filtered_num_hours > 0 else 0 - _hourly_pressure = filtered_hourly_data["surface_pressure"][0] if filtered_num_hours > 0 else 0 - _hourly_cloudcover = filtered_hourly_data["cloudcover_low"][0] if filtered_num_hours > 0 else 0 - _hourly_visibility = filtered_hourly_data["visibility"][0] if filtered_num_hours > 0 else 0 + hourly_dewpoint = filtered_hourly_data["dewpoint_2m"][0] if filtered_num_hours > 0 else 0 + hourly_humidity = filtered_hourly_data["relativehumidity_2m"][0] / 100 if filtered_num_hours > 0 else 0 + hourly_pressure = filtered_hourly_data["surface_pressure"][0] if filtered_num_hours > 0 else 0 + hourly_cloudcover = filtered_hourly_data["cloudcover_low"][0] if filtered_num_hours > 0 else 0 + hourly_visibility = filtered_hourly_data["visibility"][0] if filtered_num_hours > 0 else 0 # Daily weather data darksky_data["daily"] = { @@ -199,17 +199,17 @@ def openmeteo_to_darksky(data, lang): "apparentTemperatureHighTime": 0, "apparentTemperatureLow": daily_data["apparent_temperature_min"][i], "apparentTemperatureLowTime": 0, - "dewPoint": _hourly_dewpoint, - "humidity": _hourly_humidity, - "pressure": _hourly_pressure, + "dewPoint": hourly_dewpoint, + "humidity": hourly_humidity, + "pressure": hourly_pressure, "windSpeed": daily_data["windspeed_10m_max"][i], "windGust": daily_data["windgusts_10m_max"][i], "windGustTime": 0, "windBearing": daily_data["winddirection_10m_dominant"][i], - "cloudCover": _hourly_cloudcover, + "cloudCover": hourly_cloudcover, "uvIndex": daily_data["uv_index_max"][i], "uvIndexTime": 0, - "visibility": _hourly_visibility, + "visibility": hourly_visibility, "ozone": 0, "temperatureMin": daily_data["temperature_2m_min"][i], "temperatureMinTime": 0, @@ -233,15 +233,15 @@ def openmeteo_to_darksky(data, lang): "precipType": "rain", "temperature": json_data["current_weather"]["temperature"], "apparentTemperature": json_data["current_weather"]["temperature"], - "dewPoint": _hourly_dewpoint, - "humidity": _hourly_humidity, - "pressure": _hourly_pressure, + "dewPoint": hourly_dewpoint, + "humidity": hourly_humidity, + "pressure": hourly_pressure, "windSpeed": json_data["current_weather"]["windspeed"], "windGust": daily_data["windgusts_10m_max"][0], "windBearing": json_data["current_weather"]["winddirection"], - "cloudCover": _hourly_cloudcover, + "cloudCover": hourly_cloudcover, "uvIndex": daily_data["uv_index_max"][0], - "visibility": _hourly_visibility, + "visibility": hourly_visibility, "ozone": 0 } From 67a0debc8c96b0930a1ad0729b438d3c10d99dd4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 14:07:05 +0000 Subject: [PATCH 49/91] Update installation docs and add app guide Agent-Logs-Url: https://github.com/carloshm/PiWeatherRock/sessions/29242c01-d22a-48d6-b59f-b02e046faf2a Co-authored-by: carloshm <2389573+carloshm@users.noreply.github.com> --- README.md | 54 ++++++++++++++++---- docs/README.md | 76 ++++++++++++++++++++++++++++ docs/images/pantalla-diaria.svg | 34 +++++++++++++ docs/images/pantalla-horaria.svg | 34 +++++++++++++ docs/images/pantalla-informacion.svg | 15 ++++++ 5 files changed, 204 insertions(+), 9 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/images/pantalla-diaria.svg create mode 100644 docs/images/pantalla-horaria.svg create mode 100644 docs/images/pantalla-informacion.svg diff --git a/README.md b/README.md index 590b042..28de961 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,49 @@ Weather settings are configured in `piweatherrock/piweatherrock-config.json`: - `timezone`: Your timezone (e.g., `Europe/Madrid`). - `update_freq`: How often to refresh weather data (in seconds). +## Installation + +PiWeatherRock is packaged with `pyproject.toml` and installs the current +console commands `pwr-ui` and `pwr-config-upgrade`. + +### Raspberry Pi / Linux + +Use the installation script from the repository root: + +```bash +git clone https://github.com/carloshm/PiWeatherRock.git +cd PiWeatherRock +./install.sh Europe/Madrid +``` + +The optional argument is the system timezone. The script installs the required +system packages, creates a virtual environment at `~/pwr-env`, installs +PiWeatherRock with `pip install .`, and prints the commands needed to run the +application. + +Before starting the UI, create and edit your configuration file: + +```bash +source ~/pwr-env/bin/activate +cp piweatherrock/config.json-sample piweatherrock/piweatherrock-config.json +# Edit piweatherrock/piweatherrock-config.json with your coordinates, timezone, language, and display options. +pwr-ui -c ./piweatherrock/piweatherrock-config.json +``` + +### Manual or development installation + +```bash +python3 -m venv .venv +source .venv/bin/activate +python3 -m pip install --upgrade pip setuptools wheel +python3 -m pip install . +cp piweatherrock/config.json-sample piweatherrock/piweatherrock-config.json +# Edit piweatherrock/piweatherrock-config.json before running. +pwr-ui -c ./piweatherrock/piweatherrock-config.json +``` + +See [`docs/`](docs/) for an application walkthrough with screenshots. + ## Release process - Update version in `pyproject.toml` according to the types of changes made @@ -56,17 +99,10 @@ git push origin main ## Run changes -Before running for the first time, create your configuration file from the sample: - -```bash -cp piweatherrock/config.json-sample piweatherrock/piweatherrock-config.json -# Edit piweatherrock/piweatherrock-config.json with your coordinates, timezone, etc. -``` - -Then install and run: +After making code changes in a local checkout, reinstall the package in your +active virtual environment and run the UI with your configuration file: ```bash -python3 -m pip install --upgrade setuptools wheel python3 -m pip install . pwr-ui -c ./piweatherrock/piweatherrock-config.json ``` diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..7849d68 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,76 @@ +# Guía de la aplicación PiWeatherRock + +Esta carpeta documenta el uso actual de PiWeatherRock y complementa las instrucciones de instalación del `README.md` principal. + +## Qué muestra la aplicación + +PiWeatherRock es una interfaz de pantalla completa para Raspberry Pi u otros equipos con pantalla conectada. Consulta Open-Meteo, traduce los datos al formato interno heredado de Dark Sky y alterna entre pantallas de previsión diaria, previsión horaria e información general. + +## Instalación actual resumida + +La instalación vigente usa el empaquetado definido en `pyproject.toml`: + +1. Instalar dependencias del sistema y crear un entorno virtual. En Raspberry Pi/Linux se puede usar: + + ```bash + ./install.sh Europe/Madrid + ``` + +2. Activar el entorno virtual e instalar el paquete: + + ```bash + source ~/pwr-env/bin/activate + python3 -m pip install . + ``` + +3. Crear la configuración desde la plantilla y editar ubicación, zona horaria, idioma y opciones de pantalla: + + ```bash + cp piweatherrock/config.json-sample piweatherrock/piweatherrock-config.json + ``` + +4. Ejecutar la aplicación con el entry point actual: + + ```bash + pwr-ui -c ./piweatherrock/piweatherrock-config.json + ``` + +También queda disponible `pwr-config-upgrade` para actualizar configuraciones antiguas. + +## Pantalla de previsión diaria + +La pantalla diaria combina la hora, la temperatura actual, el resumen meteorológico, viento, humedad y aviso de paraguas con la previsión de hoy y los tres próximos días. + +![Captura de la previsión diaria](images/pantalla-diaria.svg) + +## Pantalla de previsión horaria + +La pantalla horaria mantiene el bloque superior de condiciones actuales y sustituye la franja inferior por la previsión de las próximas horas. Se puede alternar manualmente con la tecla `h`. + +![Captura de la previsión horaria](images/pantalla-horaria.svg) + +## Pantalla de información + +La pantalla de información reduce el contenido visual para ayudar a evitar quemados de pantalla. Muestra hora, salida y puesta de sol, duración de la luz diurna y hora de la última actualización. Se puede abrir con la tecla `i`. + +![Captura de la pantalla de información](images/pantalla-informacion.svg) + +## Controles principales + +- `d`: cambia a previsión diaria. +- `h`: cambia a previsión horaria. +- `i`: cambia a información general. +- `s`: guarda una captura como `screenshot.jpeg`. +- `q` o Intro del teclado numérico: cierra la aplicación. + +## Configuración relevante + +Los valores se editan en `piweatherrock/piweatherrock-config.json`: + +- `lat` y `lon`: coordenadas de la ubicación. +- `timezone`: zona horaria, por ejemplo `Europe/Madrid`. +- `lang` y `ui_lang`: idioma de los datos meteorológicos y de la interfaz. +- `units`: sistema de unidades; `si` usa métricas. +- `fullscreen`: ejecuta en pantalla completa si es `true`. +- `update_freq`: frecuencia de actualización de Open-Meteo en segundos. +- `plugins.daily` y `plugins.hourly`: activación y tiempo de permanencia de las pantallas diaria y horaria. diff --git a/docs/images/pantalla-diaria.svg b/docs/images/pantalla-diaria.svg new file mode 100644 index 0000000..fd7f990 --- /dev/null +++ b/docs/images/pantalla-diaria.svg @@ -0,0 +1,34 @@ + + Captura representativa de la previsión diaria de PiWeatherRock + Pantalla negra con bordes blancos, hora, temperatura actual, condiciones y previsión de cuatro días. + + + + + + + + + + + 14:02 hr + 21° C + Parcialmente nuboso + Sensación 22° C + Viento NE @ 12 km/h + Humedad 54% + No hace falta paraguas + Hoy + + 24° / 13° + Viernes + + 22° / 12° + Sábado + + 19° / 11° + Domingo + + 23° / 14° + + diff --git a/docs/images/pantalla-horaria.svg b/docs/images/pantalla-horaria.svg new file mode 100644 index 0000000..ab09222 --- /dev/null +++ b/docs/images/pantalla-horaria.svg @@ -0,0 +1,34 @@ + + Captura representativa de la previsión horaria de PiWeatherRock + Pantalla de previsión horaria con condiciones actuales y cuatro intervalos de próximas horas. + + + + + + + + + + + 14:02 hr + 21° C + Cielo claro + Sensación 22° C + Viento E @ 10 km/h + Humedad 48% + No hace falta paraguas + 14 hr + + 21° + 15 hr + + 22° + 16 hr + + 21° + 17 hr + + 20° + + diff --git a/docs/images/pantalla-informacion.svg b/docs/images/pantalla-informacion.svg new file mode 100644 index 0000000..aaa3310 --- /dev/null +++ b/docs/images/pantalla-informacion.svg @@ -0,0 +1,15 @@ + + Captura representativa de la pantalla de información de PiWeatherRock + Pantalla de información con hora, salida y puesta de sol, horas de luz y última actualización. + + + 14:02 hr + Desarrollado por PiWeatherRock + Salida del sol: 07:08 hoy + Puesta del sol: 21:10 esta noche + Luz diurna: 14 horas 2 minutos + Quedan 7 horas y 8 minutos para la puesta del sol + Última comprobación: + 14:00:01 CEST on Thu. 07 May 2026 + + From 5870fcea4d5f568bcc785c063d2e1fe88719db6c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 14:08:25 +0000 Subject: [PATCH 50/91] Clarify docs installation flow Agent-Logs-Url: https://github.com/carloshm/PiWeatherRock/sessions/29242c01-d22a-48d6-b59f-b02e046faf2a Co-authored-by: carloshm <2389573+carloshm@users.noreply.github.com> --- docs/README.md | 41 ++++++++++++++++++++++------------------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/docs/README.md b/docs/README.md index 7849d68..e6d2034 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,32 +8,35 @@ PiWeatherRock es una interfaz de pantalla completa para Raspberry Pi u otros equ ## Instalación actual resumida -La instalación vigente usa el empaquetado definido en `pyproject.toml`: +La instalación vigente usa el empaquetado definido en `pyproject.toml`. -1. Instalar dependencias del sistema y crear un entorno virtual. En Raspberry Pi/Linux se puede usar: +En Raspberry Pi/Linux se puede usar el script del repositorio: - ```bash - ./install.sh Europe/Madrid - ``` +```bash +./install.sh Europe/Madrid +``` -2. Activar el entorno virtual e instalar el paquete: +El script instala dependencias del sistema, crea el entorno virtual `~/pwr-env` e instala el paquete con `pip install .`. - ```bash - source ~/pwr-env/bin/activate - python3 -m pip install . - ``` +Después, activa el entorno, crea la configuración desde la plantilla y ejecuta el entry point actual: -3. Crear la configuración desde la plantilla y editar ubicación, zona horaria, idioma y opciones de pantalla: +```bash +source ~/pwr-env/bin/activate +cp piweatherrock/config.json-sample piweatherrock/piweatherrock-config.json +# Edita piweatherrock/piweatherrock-config.json con ubicación, zona horaria, idioma y opciones de pantalla. +pwr-ui -c ./piweatherrock/piweatherrock-config.json +``` - ```bash - cp piweatherrock/config.json-sample piweatherrock/piweatherrock-config.json - ``` +Para una instalación manual o de desarrollo: -4. Ejecutar la aplicación con el entry point actual: - - ```bash - pwr-ui -c ./piweatherrock/piweatherrock-config.json - ``` +```bash +python3 -m venv .venv +source .venv/bin/activate +python3 -m pip install --upgrade pip setuptools wheel +python3 -m pip install . +cp piweatherrock/config.json-sample piweatherrock/piweatherrock-config.json +pwr-ui -c ./piweatherrock/piweatherrock-config.json +``` También queda disponible `pwr-config-upgrade` para actualizar configuraciones antiguas. From 566d7b13d915de9e2d7c8c650dd0c0c4458bfd68 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 21:15:53 +0000 Subject: [PATCH 51/91] Add configuration hot reload and web UI Agent-Logs-Url: https://github.com/carloshm/PiWeatherRock/sessions/e6dad989-6a44-4a57-b03a-27cbba89ffab Co-authored-by: carloshm <2389573+carloshm@users.noreply.github.com> --- README.md | 23 ++- piweatherrock/config_manager.py | 301 ++++++++++++++++++++++++++++ piweatherrock/pwr_config_upgrade.py | 14 +- piweatherrock/pwr_config_web.py | 149 ++++++++++++++ piweatherrock/runner.py | 48 ++++- piweatherrock/weather.py | 89 +++++--- pyproject.toml | 1 + tests/test_config_manager.py | 101 ++++++++++ 8 files changed, 688 insertions(+), 38 deletions(-) create mode 100644 piweatherrock/config_manager.py create mode 100644 piweatherrock/pwr_config_web.py create mode 100644 tests/test_config_manager.py diff --git a/README.md b/README.md index 590b042..19af8cb 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,21 @@ Weather settings are configured in `piweatherrock/piweatherrock-config.json`: - `lang`: Language for weather descriptions. - `timezone`: Your timezone (e.g., `Europe/Madrid`). - `update_freq`: How often to refresh weather data (in seconds). +- `fullscreen`, `12hour_disp`, `icon_offset`: Display behavior. +- `info_pause`, `info_delay`, `plugins`: Page rotation behavior. + +PiWeatherRock automatically checks the config file while `pwr-ui` is running. +Valid changes are applied without restarting the display. If the JSON is invalid, +the active configuration remains in use and the error is logged. + +You can edit the same JSON from the local web configuration UI: + +```bash +pwr-config-web -c ./piweatherrock/piweatherrock-config.json +``` + +The config UI binds to `127.0.0.1:8888` by default. Use `--host` and `--port` +only when you intentionally want to expose it elsewhere on your network. ## Release process @@ -71,7 +86,13 @@ python3 -m pip install . pwr-ui -c ./piweatherrock/piweatherrock-config.json ``` -> **Note:** `pwr-ui` and `pwr-config-upgrade` are installed as console entry points via `pyproject.toml`. See [PEP 621](https://peps.python.org/pep-0621/) and [setup.py deprecation](https://blog.ganssle.io/articles/2021/10/setup-py-deprecated.html) for background. +To configure from a browser: + +```bash +pwr-config-web -c ./piweatherrock/piweatherrock-config.json +``` + +> **Note:** `pwr-ui`, `pwr-config-web`, and `pwr-config-upgrade` are installed as console entry points via `pyproject.toml`. See [PEP 621](https://peps.python.org/pep-0621/) and [setup.py deprecation](https://blog.ganssle.io/articles/2021/10/setup-py-deprecated.html) for background. ## Validate Service Data diff --git a/piweatherrock/config_manager.py b/piweatherrock/config_manager.py new file mode 100644 index 0000000..134702a --- /dev/null +++ b/piweatherrock/config_manager.py @@ -0,0 +1,301 @@ +# -*- coding: utf-8 -*- +"""Configuration loading, validation, and hot-reload helpers.""" + +import copy +import json +import os +import shutil +import tempfile +import time + + +class ConfigError(Exception): + """Raised when a configuration file cannot be loaded or validated.""" + + +REQUIRED_FIELDS = { + "ds_api_key": str, + "lat": (int, float), + "lon": (int, float), + "units": str, + "lang": str, + "ui_lang": str, + "timezone": str, + "fullscreen": bool, + "12hour_disp": bool, + "icon_offset": (int, float), + "update_freq": int, + "info_pause": int, + "info_delay": int, + "plugins": dict, + "log_level": str, +} + +PLUGIN_FIELDS = { + "enabled": bool, + "pause": int, +} + +WEATHER_RELOAD_PATHS = { + ("ds_api_key",), + ("lat",), + ("lon",), + ("units",), + ("lang",), + ("timezone",), + ("update_freq",), +} + +DISPLAY_RELOAD_PATHS = { + ("fullscreen",), +} + +SUN_TIME_RELOAD_PATHS = { + ("12hour_disp",), + ("ui_lang",), +} + +LOG_RELOAD_PATHS = { + ("log_level",), +} + +ROTATION_RELOAD_PATHS = { + ("info_pause",), + ("info_delay",), + ("plugins", "daily", "pause"), + ("plugins", "hourly", "pause"), + ("plugins", "daily", "enabled"), + ("plugins", "hourly", "enabled"), +} + + +CONFIG_FORM_FIELDS = [ + (("lat",), "Latitud", "float"), + (("lon",), "Longitud", "float"), + (("timezone",), "Zona horaria", "text"), + (("ds_api_key",), "Identificador Open-Meteo", "text"), + (("units",), "Unidades", "text"), + (("lang",), "Idioma meteorológico", "text"), + (("ui_lang",), "Idioma de interfaz", "text"), + (("update_freq",), "Frecuencia forecast (segundos)", "int"), + (("fullscreen",), "Pantalla completa", "bool"), + (("12hour_disp",), "Formato 12 horas", "bool"), + (("icon_offset",), "Offset de iconos", "float"), + (("info_pause",), "Pausa info (segundos)", "int"), + (("info_delay",), "Retraso info (segundos)", "int"), + (("plugins", "daily", "enabled"), "Página diaria activa", "bool"), + (("plugins", "daily", "pause"), "Pausa diaria (segundos)", "int"), + (("plugins", "hourly", "enabled"), "Página horaria activa", "bool"), + (("plugins", "hourly", "pause"), "Pausa horaria (segundos)", "int"), + (("log_level",), "Nivel de log", "text"), +] + + +def load_config(config_file): + """Load and validate a PiWeatherRock JSON configuration.""" + try: + with open(config_file, "r") as f: + config = json.load(f) + except (IOError, ValueError) as exc: + raise ConfigError("Could not load config file '{}': {}".format( + config_file, exc)) + + validate_config(config) + return config + + +def load_sample_config(sample_file): + """Load a sample/default config without requiring the target file to exist.""" + return load_config(sample_file) + + +def validate_config(config): + """Validate required fields and value ranges.""" + errors = [] + + if not isinstance(config, dict): + raise ConfigError("Config must be a JSON object") + + for key, expected_type in REQUIRED_FIELDS.items(): + if key not in config: + errors.append("Missing required field '{}'".format(key)) + elif not _is_expected_type(config[key], expected_type): + errors.append("Field '{}' has invalid type".format(key)) + + plugins = config.get("plugins") + if isinstance(plugins, dict): + for plugin_name in ("daily", "hourly"): + plugin_config = plugins.get(plugin_name) + if not isinstance(plugin_config, dict): + errors.append("Missing plugin '{}' configuration".format(plugin_name)) + continue + for key, expected_type in PLUGIN_FIELDS.items(): + if key not in plugin_config: + errors.append("Missing plugins.{}.{}".format(plugin_name, key)) + elif not _is_expected_type(plugin_config[key], expected_type): + errors.append("Field plugins.{}.{} has invalid type".format( + plugin_name, key)) + + _validate_range(config, "lat", -90, 90, errors) + _validate_range(config, "lon", -180, 180, errors) + _validate_positive_int(config, "update_freq", errors) + _validate_positive_int(config, "info_pause", errors) + _validate_positive_int(config, "info_delay", errors) + + if isinstance(plugins, dict): + for plugin_name in ("daily", "hourly"): + plugin_config = plugins.get(plugin_name) + if isinstance(plugin_config, dict): + _validate_positive_int(plugin_config, "pause", errors, + "plugins.{}.pause".format(plugin_name)) + + if errors: + raise ConfigError("Invalid configuration: " + "; ".join(errors)) + + return True + + +def merge_defaults(config, default_config): + """Return a copy of config with missing keys filled from default_config.""" + merged = copy.deepcopy(config) + for key, value in default_config.items(): + if key not in merged: + merged[key] = copy.deepcopy(value) + elif isinstance(merged[key], dict) and isinstance(value, dict): + merged[key] = merge_defaults(merged[key], value) + return merged + + +def write_config_atomic(config_file, config, backup=True): + """Validate and write config atomically, preserving the previous file.""" + validate_config(config) + + config_dir = os.path.dirname(os.path.abspath(config_file)) or "." + if not os.path.isdir(config_dir): + os.makedirs(config_dir) + + if backup and os.path.exists(config_file): + shutil.copy2(config_file, config_file + ".bak") + + fd, temp_file = tempfile.mkstemp( + prefix=".{}.".format(os.path.basename(config_file)), + suffix=".tmp", + dir=config_dir) + try: + with os.fdopen(fd, "w") as f: + json.dump(config, f, indent=4, sort_keys=True) + f.write("\n") + os.replace(temp_file, config_file) + except Exception: + try: + os.remove(temp_file) + except OSError: + pass + raise + + +def diff_config(old_config, new_config): + """Return leaf paths whose values changed between two config dicts.""" + changed = set() + _collect_diff((), old_config, new_config, changed) + return changed + + +def config_changed(changed_paths, interesting_paths): + """Return True if any changed path affects one of the interesting paths.""" + for changed_path in changed_paths: + for interesting_path in interesting_paths: + if _path_related(changed_path, interesting_path): + return True + return False + + +def get_config_value(config, path): + value = config + for part in path: + value = value[part] + return value + + +def set_config_value(config, path, value): + target = config + for part in path[:-1]: + target = target.setdefault(part, {}) + target[path[-1]] = value + + +def field_name(path): + return "__".join(path) + + +class ConfigWatcher: + """Polls a config file and reports validated changes.""" + + def __init__(self, config_file, interval=1.0): + self.config_file = config_file + self.interval = interval + self.last_check = 0 + self.last_signature = file_signature(config_file) + + def changed_config(self): + now = time.time() + if now - self.last_check < self.interval: + return None + self.last_check = now + + signature = file_signature(self.config_file) + if signature == self.last_signature: + return None + + config = load_config(self.config_file) + return signature, config + + def commit(self, signature): + self.last_signature = signature + + def sync_signature(self): + self.last_signature = file_signature(self.config_file) + + +def file_signature(config_file): + stat = os.stat(config_file) + mtime = getattr(stat, "st_mtime_ns", int(stat.st_mtime * 1000000000)) + return mtime, stat.st_size + + +def _is_expected_type(value, expected_type): + if expected_type is bool: + return isinstance(value, bool) + if expected_type in (int, (int, float)): + return isinstance(value, expected_type) and not isinstance(value, bool) + return isinstance(value, expected_type) + + +def _validate_range(config, key, minimum, maximum, errors): + value = config.get(key) + if isinstance(value, (int, float)) and not isinstance(value, bool): + if value < minimum or value > maximum: + errors.append("Field '{}' must be between {} and {}".format( + key, minimum, maximum)) + + +def _validate_positive_int(config, key, errors, display_name=None): + value = config.get(key) + if isinstance(value, int) and not isinstance(value, bool) and value <= 0: + errors.append("Field '{}' must be greater than 0".format( + display_name or key)) + + +def _collect_diff(path, old_value, new_value, changed): + if isinstance(old_value, dict) and isinstance(new_value, dict): + keys = set(old_value.keys()) | set(new_value.keys()) + for key in keys: + _collect_diff(path + (key,), old_value.get(key), new_value.get(key), + changed) + elif old_value != new_value: + changed.add(path) + + +def _path_related(changed_path, interesting_path): + shortest = min(len(changed_path), len(interesting_path)) + return changed_path[:shortest] == interesting_path[:shortest] diff --git a/piweatherrock/pwr_config_upgrade.py b/piweatherrock/pwr_config_upgrade.py index 9114be2..3176159 100644 --- a/piweatherrock/pwr_config_upgrade.py +++ b/piweatherrock/pwr_config_upgrade.py @@ -13,6 +13,7 @@ import socket from argparse import ArgumentParser +from piweatherrock.config_manager import load_config, merge_defaults, write_config_atomic pi_ip = socket.gethostbyname(socket.gethostname() + ".local") @@ -103,21 +104,16 @@ def main(): old_config = json.load(f) elif os.path.exists(sample_file): - with open(sample_file, "r") as f: - old_config = json.load(f) + old_config = load_config(sample_file) print(f"\nYou must configure PiWeatherRock.\n\n" f"Go to http://{pi_ip}:8888 to configure.\n") - with open(sample_file, "r") as f: - new_config = json.load(f) + new_config = load_config(sample_file) # Add any new config variables - for key in new_config.keys(): - if key not in old_config.keys(): - old_config[key] = new_config[key] + old_config = merge_defaults(old_config, new_config) - with open(config_file, "w") as f: - json.dump(old_config, f) + write_config_atomic(config_file, old_config) if __name__ == '__main__': diff --git a/piweatherrock/pwr_config_web.py b/piweatherrock/pwr_config_web.py new file mode 100644 index 0000000..4e4c616 --- /dev/null +++ b/piweatherrock/pwr_config_web.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Local web configuration UI for PiWeatherRock.""" + +import html +import os +from argparse import ArgumentParser + +import cherrypy + +from piweatherrock.config_manager import ( + CONFIG_FORM_FIELDS, + ConfigError, + field_name, + get_config_value, + load_config, + set_config_value, + validate_config, + write_config_atomic, +) + + +class ConfigWebApp: + def __init__(self, config_file): + self.config_file = config_file + + @cherrypy.expose + def index(self, message=""): + try: + config = load_config(self.config_file) + body = self._render_form(config, message) + except ConfigError as exc: + body = self._page("Error de configuración", "

{}

".format( + html.escape(str(exc)))) + return body + + @cherrypy.expose + def save(self, **params): + try: + config = load_config(self.config_file) + for path, label, field_type in CONFIG_FORM_FIELDS: + name = field_name(path) + value = self._coerce_value(params.get(name), field_type) + set_config_value(config, path, value) + validate_config(config) + write_config_atomic(self.config_file, config) + raise cherrypy.HTTPRedirect("/?message=" + + "Cambios%20aplicados") + except (ConfigError, ValueError) as exc: + try: + config = load_config(self.config_file) + return self._render_form(config, "Error: {}".format(exc)) + except ConfigError: + return self._page("Error", "

{}

".format( + html.escape(str(exc)))) + + @cherrypy.expose + def status(self): + try: + load_config(self.config_file) + return "OK: configuración válida. La UI aplicará los cambios automáticamente." + except ConfigError as exc: + cherrypy.response.status = 400 + return "ERROR: {}".format(exc) + + def _render_form(self, config, message=""): + rows = [] + if message: + rows.append('

{}

'.format(html.escape(message))) + rows.append('
') + rows.append('
Configuración PiWeatherRock') + for path, label, field_type in CONFIG_FORM_FIELDS: + value = get_config_value(config, path) + name = field_name(path) + rows.append(''.format( + name=html.escape(name), label=html.escape(label))) + if field_type == "bool": + checked = " checked" if value else "" + rows.append(''.format( + html.escape(name), checked)) + else: + input_type = "number" if field_type in ("int", "float") else "text" + step = ' step="any"' if field_type == "float" else "" + rows.append(''.format( + type=input_type, + name=html.escape(name), + value=html.escape(str(value)), + step=step)) + rows.append('
') + rows.append('') + rows.append('
') + rows.append('

Validar configuración

') + return self._page("PiWeatherRock Config", "\n".join(rows)) + + def _page(self, title, body): + return """ + + + + {title} + + + +

{title}

+ {body} + +""".format(title=html.escape(title), body=body) + + def _coerce_value(self, raw_value, field_type): + if field_type == "bool": + return raw_value == "true" + if raw_value is None: + raw_value = "" + if field_type == "int": + return int(raw_value) + if field_type == "float": + return float(raw_value) + return raw_value.strip() + + +def main(): + parser = ArgumentParser("Runs the local PiWeatherRock config UI") + parser.add_argument('-c', '--config', required=True, + help='Path to your config file') + parser.add_argument('--host', default='127.0.0.1', + help='Host to bind to; defaults to localhost') + parser.add_argument('--port', default=8888, type=int, + help='Port to bind to') + args = parser.parse_args() + + config_file = os.path.abspath(args.config) + cherrypy.config.update({ + 'server.socket_host': args.host, + 'server.socket_port': args.port, + }) + print("PiWeatherRock config UI: http://{}:{}".format( + args.host, args.port)) + cherrypy.quickstart(ConfigWebApp(config_file)) + + +if __name__ == '__main__': + main() diff --git a/piweatherrock/runner.py b/piweatherrock/runner.py index fec1388..9b0e15d 100644 --- a/piweatherrock/runner.py +++ b/piweatherrock/runner.py @@ -3,7 +3,6 @@ # Copyright (c) 2017 Gene Liverman # Distributed under the MIT License (https://opensource.org/licenses/MIT) -import json import pygame import sys import time @@ -15,6 +14,14 @@ from pygame.locals import QUIT, VIDEORESIZE, KEYDOWN, K_KP_ENTER, K_q, K_d, K_h, K_i, K_s # local imports +from piweatherrock.config_manager import ( + ConfigError, + ConfigWatcher, + ROTATION_RELOAD_PATHS, + config_changed, + diff_config, + load_config, +) from piweatherrock.weather import Weather from piweatherrock.plugin_weather_daily import PluginWeatherDaily from piweatherrock.plugin_weather_hourly import PluginWeatherHourly @@ -36,10 +43,11 @@ def __init__(self): self.daily = None self.hourly = None self.info = None + self.config_watcher = None def main(self, config_file): - with open(config_file, "r") as f: - self.config = json.load(f) + self.config = load_config(config_file) + self.config_watcher = ConfigWatcher(config_file) pygame.init() # Create an instance of the main application class @@ -80,6 +88,7 @@ def main(self, config_file): while self.running: # Look for and process keyboard events to change modes. self.process_pygame_events() + self.check_config_reload() self.screen_switcher() # Loop timer. @@ -232,3 +241,36 @@ def check_forecast(self): except BaseException: self.my_weather_rock.log.exception( f"Unexpected error: {sys.exc_info()[0]}") + + def check_config_reload(self): + try: + changed = self.config_watcher.changed_config() + except ConfigError as e: + self.config_watcher.sync_signature() + self.my_weather_rock.log.warning( + f"Ignoring invalid config reload: {e}") + return + except OSError as e: + self.my_weather_rock.log.warning( + f"Could not check config reload: {e}") + return + + if changed is None: + return + + signature, new_config = changed + changed_paths = diff_config(self.config, new_config) + try: + self.my_weather_rock.reload_config(new_config, changed_paths) + except Exception: + self.my_weather_rock.log.exception( + "Error applying config reload") + return + + if config_changed(changed_paths, ROTATION_RELOAD_PATHS): + self.periodic_info_activation = 0 + self.non_weather_timeout = 0 + + self.config = new_config + self.config_watcher.commit(signature) + self.my_weather_rock.log.info("Configuration reloaded") diff --git a/piweatherrock/weather.py b/piweatherrock/weather.py index b05e0e3..4f7d3cc 100644 --- a/piweatherrock/weather.py +++ b/piweatherrock/weather.py @@ -10,7 +10,6 @@ import signal import sys import time -import json import logging import logging.handlers @@ -20,6 +19,14 @@ import requests # local imports +from piweatherrock.config_manager import ( + DISPLAY_RELOAD_PATHS, + LOG_RELOAD_PATHS, + SUN_TIME_RELOAD_PATHS, + WEATHER_RELOAD_PATHS, + config_changed, + load_config, +) from piweatherrock.intl import intl @@ -39,8 +46,7 @@ class Weather: """ def __init__(self, config_file): - with open(config_file, "r") as f: - self.config = json.load(f) + self.config = load_config(config_file) #Initialize locale intl self.intl = intl() @@ -167,28 +173,7 @@ def get_forecast(self): sunset_today = datetime.datetime.fromtimestamp( self.weather.daily[0].sunsetTime) - if datetime.datetime.now() < sunset_today: - index = 0 - sr_suffix = self.intl.get_text(self.ui_lang,"today") - ss_suffix = self.intl.get_text(self.ui_lang,"tonight") - else: - index = 1 - sr_suffix = self.intl.get_text(self.ui_lang,"tomorrow") - ss_suffix = self.intl.get_text(self.ui_lang,"tomorrow") - - self.sunrise = self.weather.daily[index].sunriseTime - self.sunset = self.weather.daily[index].sunsetTime - - if self.config["12hour_disp"]: - self.sunrise_string = datetime.datetime.fromtimestamp( - self.sunrise).strftime("%I:%M %p {}").format(sr_suffix) - self.sunset_string = datetime.datetime.fromtimestamp( - self.sunset).strftime("%I:%M %p {}").format(ss_suffix) - else: - self.sunrise_string = datetime.datetime.fromtimestamp( - self.sunrise).strftime("%H:%M {}").format(sr_suffix) - self.sunset_string = datetime.datetime.fromtimestamp( - self.sunset).strftime("%H:%M {}").format(ss_suffix) + self.update_sun_strings(sunset_today) # Only update the check time after a successful fetch self.last_update_check = time.time() @@ -201,6 +186,60 @@ def get_forecast(self): return False return True + def reload_config(self, new_config, changed_paths): + """ + Apply a validated configuration change without restarting the UI. + """ + self.config = new_config + self.ui_lang = self.config["ui_lang"] + + if config_changed(changed_paths, LOG_RELOAD_PATHS): + self.log = self.get_logger() + + if config_changed(changed_paths, DISPLAY_RELOAD_PATHS): + size = (pygame.display.Info().current_w, + pygame.display.Info().current_h) + self.sizing(size) + self.screen.fill((0, 0, 0)) + pygame.display.update() + + if config_changed(changed_paths, WEATHER_RELOAD_PATHS): + self.last_update_check = 0 + self.get_forecast() + elif config_changed(changed_paths, SUN_TIME_RELOAD_PATHS): + self.update_sun_strings() + + def update_sun_strings(self, sunset_today=None): + if not hasattr(self.weather, "daily"): + return + + if sunset_today is None: + sunset_today = datetime.datetime.fromtimestamp( + self.weather.daily[0].sunsetTime) + + if datetime.datetime.now() < sunset_today: + index = 0 + sr_suffix = self.intl.get_text(self.ui_lang, "today") + ss_suffix = self.intl.get_text(self.ui_lang, "tonight") + else: + index = 1 + sr_suffix = self.intl.get_text(self.ui_lang, "tomorrow") + ss_suffix = self.intl.get_text(self.ui_lang, "tomorrow") + + self.sunrise = self.weather.daily[index].sunriseTime + self.sunset = self.weather.daily[index].sunsetTime + + if self.config["12hour_disp"]: + self.sunrise_string = datetime.datetime.fromtimestamp( + self.sunrise).strftime("%I:%M %p {}").format(sr_suffix) + self.sunset_string = datetime.datetime.fromtimestamp( + self.sunset).strftime("%I:%M %p {}").format(ss_suffix) + else: + self.sunrise_string = datetime.datetime.fromtimestamp( + self.sunrise).strftime("%H:%M {}").format(sr_suffix) + self.sunset_string = datetime.datetime.fromtimestamp( + self.sunset).strftime("%H:%M {}").format(ss_suffix) + def screen_cap(self): """ Save a jpg image of the screen diff --git a/pyproject.toml b/pyproject.toml index fe930a9..6ff1b66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ Homepage = "https://github.com/carloshm/PiWeatherRock" [project.scripts] pwr-ui = "piweatherrock.pwr_ui:main" +pwr-config-web = "piweatherrock.pwr_config_web:main" pwr-config-upgrade = "piweatherrock.pwr_config_upgrade:main" [tool.setuptools.packages.find] diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py new file mode 100644 index 0000000..aeaa828 --- /dev/null +++ b/tests/test_config_manager.py @@ -0,0 +1,101 @@ +import json +import os +import tempfile +import unittest + +from piweatherrock.config_manager import ( + ConfigError, + ConfigWatcher, + diff_config, + load_config, + merge_defaults, + write_config_atomic, +) + + +VALID_CONFIG = { + "ds_api_key": "openmeteo-request-piweatherrock", + "lat": 40.299457, + "lon": -3.743399, + "units": "si", + "lang": "es", + "ui_lang": "es", + "timezone": "Europe/Madrid", + "fullscreen": True, + "12hour_disp": False, + "icon_offset": -23.5, + "update_freq": 900, + "info_pause": 60, + "info_delay": 900, + "plugins": { + "daily": {"pause": 60, "enabled": True}, + "hourly": {"pause": 60, "enabled": True}, + }, + "log_level": "INFO", +} + + +class ConfigManagerTest(unittest.TestCase): + def test_load_config_rejects_invalid_json(self): + with tempfile.NamedTemporaryFile("w", delete=False) as f: + f.write("{") + path = f.name + try: + with self.assertRaises(ConfigError): + load_config(path) + finally: + os.remove(path) + + def test_write_config_atomic_creates_backup_and_valid_file(self): + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "config.json") + write_config_atomic(path, VALID_CONFIG, backup=False) + updated = dict(VALID_CONFIG) + updated["lat"] = 1.25 + write_config_atomic(path, updated) + + self.assertTrue(os.path.exists(path + ".bak")) + self.assertEqual(load_config(path)["lat"], 1.25) + self.assertEqual(load_config(path + ".bak")["lat"], VALID_CONFIG["lat"]) + + def test_write_config_atomic_rejects_invalid_config(self): + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "config.json") + invalid = dict(VALID_CONFIG) + invalid["lat"] = 91 + with self.assertRaises(ConfigError): + write_config_atomic(path, invalid, backup=False) + self.assertFalse(os.path.exists(path)) + + def test_diff_config_reports_nested_changes(self): + updated = json.loads(json.dumps(VALID_CONFIG)) + updated["plugins"]["daily"]["pause"] = 30 + self.assertEqual( + diff_config(VALID_CONFIG, updated), + {("plugins", "daily", "pause")}) + + def test_merge_defaults_adds_nested_missing_keys(self): + partial = {"plugins": {"daily": {"pause": 30}}} + defaults = {"plugins": {"daily": {"enabled": True}, "hourly": {"pause": 60}}} + merged = merge_defaults(partial, defaults) + self.assertTrue(merged["plugins"]["daily"]["enabled"]) + self.assertEqual(merged["plugins"]["hourly"]["pause"], 60) + + def test_config_watcher_detects_file_replacement(self): + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "config.json") + write_config_atomic(path, VALID_CONFIG, backup=False) + watcher = ConfigWatcher(path, interval=0) + updated = dict(VALID_CONFIG) + updated["update_freq"] = 300 + write_config_atomic(path, updated, backup=False) + result = watcher.changed_config() + self.assertIsNotNone(result) + signature, config = result + self.assertEqual(config["update_freq"], 300) + watcher.commit(signature) + self.assertIsNone(watcher.changed_config()) + + +if __name__ == "__main__": + unittest.main() From 62d8e0ed74ee8a1809ecd1583e8cf90ed9cdc62b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 21:16:37 +0000 Subject: [PATCH 52/91] Respect enabled weather pages during reload Agent-Logs-Url: https://github.com/carloshm/PiWeatherRock/sessions/e6dad989-6a44-4a57-b03a-27cbba89ffab Co-authored-by: carloshm <2389573+carloshm@users.noreply.github.com> --- piweatherrock/runner.py | 96 ++++++++++++++++++++++++++++++----------- 1 file changed, 70 insertions(+), 26 deletions(-) diff --git a/piweatherrock/runner.py b/piweatherrock/runner.py index 9b0e15d..aaded44 100644 --- a/piweatherrock/runner.py +++ b/piweatherrock/runner.py @@ -59,10 +59,7 @@ def main(self, config_file): self.info = PluginInfo(self.my_weather_rock) # Default to weather mode. Showing daily weather first. - self.current_screen = 'd' - - self.d_count = 1 - self.h_count = 0 + self.switch_to_default_weather_screen() # Stay running while True self.running = True @@ -117,19 +114,11 @@ def process_pygame_events(self): # On 'd' key, set mode to 'daily weather'. elif event.key == K_d: - self.current_screen = 'd' - self.d_count = 1 - self.h_count = 0 - self.non_weather_timeout = 0 - self.periodic_info_activation = 0 + self.switch_to_weather_screen('d') # on 'h' key, set mode to 'hourly weather' elif event.key == K_h: - self.current_screen = 'h' - self.d_count = 0 - self.h_count = 1 - self.non_weather_timeout = 0 - self.periodic_info_activation = 0 + self.switch_to_weather_screen('h') # On 'i' key, set mode to 'info'. elif event.key == K_i: @@ -148,6 +137,7 @@ def screen_switcher(self): This function takes care of cycling through the different screens on a regular basis. """ + self.ensure_current_screen_enabled() # Automatically switch back to weather display after a couple minutes if self.current_screen not in ('d', 'h'): @@ -157,9 +147,9 @@ def screen_switcher(self): self.h_count = 0 # Default in config.json.sample: pause for 5 minutes on info screen - if self.non_weather_timeout > (self.config["info_pause"] * 10): - self.current_screen = 'd' - self.d_count = 1 + if (self.enabled_weather_screens() + and self.non_weather_timeout > (self.config["info_pause"] * 10)): + self.switch_to_default_weather_screen() self.my_weather_rock.log.info("Switching to weather mode") else: self.non_weather_timeout = 0 @@ -174,14 +164,7 @@ def screen_switcher(self): ((self.config["plugins"]["daily"]["pause"] * self.d_count) + (self.config["plugins"]["hourly"]["pause"] * self.h_count)) * 10)) == 0: - if self.current_screen == 'd': - self.my_weather_rock.log.info("Switching to HOURLY") - self.current_screen = 'h' - self.h_count += 1 - else: - self.my_weather_rock.log.info("Switching to DAILY") - self.current_screen = 'd' - self.d_count += 1 + self.switch_to_next_weather_screen() # Daily Weather Display Mode if self.current_screen == 'd': @@ -267,10 +250,71 @@ def check_config_reload(self): "Error applying config reload") return + self.config = new_config if config_changed(changed_paths, ROTATION_RELOAD_PATHS): self.periodic_info_activation = 0 self.non_weather_timeout = 0 + self.ensure_current_screen_enabled() - self.config = new_config self.config_watcher.commit(signature) self.my_weather_rock.log.info("Configuration reloaded") + + def enabled_weather_screens(self): + enabled = [] + if self.config["plugins"]["daily"].get("enabled", True): + enabled.append('d') + if self.config["plugins"]["hourly"].get("enabled", True): + enabled.append('h') + return enabled + + def ensure_current_screen_enabled(self): + if self.current_screen in ('d', 'h'): + if self.current_screen not in self.enabled_weather_screens(): + self.switch_to_default_weather_screen() + + def switch_to_default_weather_screen(self): + enabled = self.enabled_weather_screens() + if not enabled: + self.current_screen = 'i' + self.d_count = 0 + self.h_count = 0 + else: + self.switch_to_weather_screen(enabled[0]) + + def switch_to_weather_screen(self, screen): + if screen not in self.enabled_weather_screens(): + if self.my_weather_rock is not None: + self.my_weather_rock.log.warning( + f"Ignoring disabled weather screen: {screen}") + return + + self.current_screen = screen + self.d_count = 1 if screen == 'd' else 0 + self.h_count = 1 if screen == 'h' else 0 + self.non_weather_timeout = 0 + self.periodic_info_activation = 0 + + def switch_to_next_weather_screen(self): + enabled = self.enabled_weather_screens() + if not enabled: + self.current_screen = 'i' + self.d_count = 0 + self.h_count = 0 + return + + if self.current_screen == 'd' and 'h' in enabled: + self.my_weather_rock.log.info("Switching to HOURLY") + self.current_screen = 'h' + self.h_count += 1 + elif self.current_screen == 'h' and 'd' in enabled: + self.my_weather_rock.log.info("Switching to DAILY") + self.current_screen = 'd' + self.d_count += 1 + elif enabled[0] == 'd': + self.my_weather_rock.log.info("Staying on DAILY") + self.current_screen = 'd' + self.d_count += 1 + else: + self.my_weather_rock.log.info("Staying on HOURLY") + self.current_screen = 'h' + self.h_count += 1 From 0158f8480d052016c6d4aa5a0c321bf315d7be0f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 21:17:45 +0000 Subject: [PATCH 53/91] Address config UI review feedback Agent-Logs-Url: https://github.com/carloshm/PiWeatherRock/sessions/e6dad989-6a44-4a57-b03a-27cbba89ffab Co-authored-by: carloshm <2389573+carloshm@users.noreply.github.com> --- piweatherrock/config_manager.py | 5 --- piweatherrock/pwr_config_web.py | 56 ++++++++++++++++++++++++++------- 2 files changed, 45 insertions(+), 16 deletions(-) diff --git a/piweatherrock/config_manager.py b/piweatherrock/config_manager.py index 134702a..7e7be38 100644 --- a/piweatherrock/config_manager.py +++ b/piweatherrock/config_manager.py @@ -104,11 +104,6 @@ def load_config(config_file): return config -def load_sample_config(sample_file): - """Load a sample/default config without requiring the target file to exist.""" - return load_config(sample_file) - - def validate_config(config): """Validate required fields and value ranges.""" errors = [] diff --git a/piweatherrock/pwr_config_web.py b/piweatherrock/pwr_config_web.py index 4e4c616..eba8af6 100644 --- a/piweatherrock/pwr_config_web.py +++ b/piweatherrock/pwr_config_web.py @@ -5,6 +5,7 @@ import html import os from argparse import ArgumentParser +from urllib.parse import urlencode import cherrypy @@ -20,6 +21,30 @@ ) +TEXT = { + "en": { + "changes_applied": "Changes applied", + "config_error": "Configuration error", + "error": "Error", + "legend": "PiWeatherRock configuration", + "save": "Save changes", + "status_ok": "OK: valid configuration. The UI will apply changes automatically.", + "title": "PiWeatherRock Config", + "validate": "Validate configuration", + }, + "es": { + "changes_applied": "Cambios aplicados", + "config_error": "Error de configuración", + "error": "Error", + "legend": "Configuración PiWeatherRock", + "save": "Guardar cambios", + "status_ok": "OK: configuración válida. La UI aplicará los cambios automáticamente.", + "title": "PiWeatherRock Config", + "validate": "Validar configuración", + }, +} + + class ConfigWebApp: def __init__(self, config_file): self.config_file = config_file @@ -30,8 +55,8 @@ def index(self, message=""): config = load_config(self.config_file) body = self._render_form(config, message) except ConfigError as exc: - body = self._page("Error de configuración", "

{}

".format( - html.escape(str(exc)))) + body = self._page(TEXT["en"]["config_error"], + "

{}

".format(html.escape(str(exc)))) return body @cherrypy.expose @@ -44,21 +69,21 @@ def save(self, **params): set_config_value(config, path, value) validate_config(config) write_config_atomic(self.config_file, config) - raise cherrypy.HTTPRedirect("/?message=" + - "Cambios%20aplicados") + raise cherrypy.HTTPRedirect("/?" + urlencode({ + "message": self._text(config, "changes_applied")})) except (ConfigError, ValueError) as exc: try: config = load_config(self.config_file) return self._render_form(config, "Error: {}".format(exc)) except ConfigError: - return self._page("Error", "

{}

".format( + return self._page(TEXT["en"]["error"], "

{}

".format( html.escape(str(exc)))) @cherrypy.expose def status(self): try: - load_config(self.config_file) - return "OK: configuración válida. La UI aplicará los cambios automáticamente." + config = load_config(self.config_file) + return self._text(config, "status_ok") except ConfigError as exc: cherrypy.response.status = 400 return "ERROR: {}".format(exc) @@ -68,7 +93,8 @@ def _render_form(self, config, message=""): if message: rows.append('

{}

'.format(html.escape(message))) rows.append('
') - rows.append('
Configuración PiWeatherRock') + rows.append('
{}'.format( + html.escape(self._text(config, "legend")))) for path, label, field_type in CONFIG_FORM_FIELDS: value = get_config_value(config, path) name = field_name(path) @@ -87,10 +113,12 @@ def _render_form(self, config, message=""): value=html.escape(str(value)), step=step)) rows.append('
') - rows.append('') + rows.append(''.format( + html.escape(self._text(config, "save")))) rows.append('') - rows.append('

Validar configuración

') - return self._page("PiWeatherRock Config", "\n".join(rows)) + rows.append('

{}

'.format( + html.escape(self._text(config, "validate")))) + return self._page(self._text(config, "title"), "\n".join(rows)) def _page(self, title, body): return """ @@ -124,6 +152,12 @@ def _coerce_value(self, raw_value, field_type): return float(raw_value) return raw_value.strip() + def _text(self, config, key): + language = config.get("ui_lang", "en") + if language not in TEXT: + language = language.split("_")[0].split("-")[0] + return TEXT.get(language, TEXT["en"])[key] + def main(): parser = ArgumentParser("Runs the local PiWeatherRock config UI") From 17b2c02cb8725ed80187240204a2189ef3cb1633 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 21:18:30 +0000 Subject: [PATCH 54/91] Address remaining validation feedback Agent-Logs-Url: https://github.com/carloshm/PiWeatherRock/sessions/e6dad989-6a44-4a57-b03a-27cbba89ffab Co-authored-by: carloshm <2389573+carloshm@users.noreply.github.com> --- piweatherrock/pwr_config_web.py | 20 +++++++++++++++----- piweatherrock/runner.py | 11 ++++++++--- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/piweatherrock/pwr_config_web.py b/piweatherrock/pwr_config_web.py index eba8af6..6f9369a 100644 --- a/piweatherrock/pwr_config_web.py +++ b/piweatherrock/pwr_config_web.py @@ -118,11 +118,12 @@ def _render_form(self, config, message=""): rows.append('') rows.append('

{}

'.format( html.escape(self._text(config, "validate")))) - return self._page(self._text(config, "title"), "\n".join(rows)) + return self._page(self._text(config, "title"), "\n".join(rows), + self._language(config)) - def _page(self, title, body): + def _page(self, title, body, language="en"): return """ - + {title} @@ -139,7 +140,10 @@ def _page(self, title, body):

{title}

{body} -""".format(title=html.escape(title), body=body) +""".format( + language=html.escape(language), + title=html.escape(title), + body=body) def _coerce_value(self, raw_value, field_type): if field_type == "bool": @@ -153,10 +157,16 @@ def _coerce_value(self, raw_value, field_type): return raw_value.strip() def _text(self, config, key): + language = self._language(config) + return TEXT.get(language, TEXT["en"])[key] + + def _language(self, config): language = config.get("ui_lang", "en") if language not in TEXT: language = language.split("_")[0].split("-")[0] - return TEXT.get(language, TEXT["en"])[key] + if language not in TEXT: + language = "en" + return language def main(): diff --git a/piweatherrock/runner.py b/piweatherrock/runner.py index aaded44..7d2a453 100644 --- a/piweatherrock/runner.py +++ b/piweatherrock/runner.py @@ -28,6 +28,9 @@ from piweatherrock.plugin_info import PluginInfo +TICKS_PER_SECOND = 10 + + class Runner: def __init__(self): @@ -148,7 +151,8 @@ def screen_switcher(self): # Default in config.json.sample: pause for 5 minutes on info screen if (self.enabled_weather_screens() - and self.non_weather_timeout > (self.config["info_pause"] * 10)): + and self.non_weather_timeout > ( + self.config["info_pause"] * TICKS_PER_SECOND)): self.switch_to_default_weather_screen() self.my_weather_rock.log.info("Switching to weather mode") else: @@ -157,13 +161,14 @@ def screen_switcher(self): # Default is to flip between 2 weather screens # for 15 minutes before showing info screen. - if self.periodic_info_activation > (self.config["info_delay"] * 10): + if self.periodic_info_activation > ( + self.config["info_delay"] * TICKS_PER_SECOND): self.current_screen = 'i' self.my_weather_rock.log.info("Switching to info mode") elif (self.periodic_info_activation % ( ((self.config["plugins"]["daily"]["pause"] * self.d_count) + (self.config["plugins"]["hourly"]["pause"] * self.h_count)) - * 10)) == 0: + * TICKS_PER_SECOND)) == 0: self.switch_to_next_weather_screen() # Daily Weather Display Mode From 89fb376f3fc82db49d3010ecf512e2e5599c876f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 21:19:27 +0000 Subject: [PATCH 55/91] Localize config UI field labels Agent-Logs-Url: https://github.com/carloshm/PiWeatherRock/sessions/e6dad989-6a44-4a57-b03a-27cbba89ffab Co-authored-by: carloshm <2389573+carloshm@users.noreply.github.com> --- piweatherrock/config_manager.py | 36 +++++++++++------------ piweatherrock/pwr_config_web.py | 52 +++++++++++++++++++++++++++++++-- piweatherrock/runner.py | 8 ++--- 3 files changed, 71 insertions(+), 25 deletions(-) diff --git a/piweatherrock/config_manager.py b/piweatherrock/config_manager.py index 7e7be38..5907443 100644 --- a/piweatherrock/config_manager.py +++ b/piweatherrock/config_manager.py @@ -70,24 +70,24 @@ class ConfigError(Exception): CONFIG_FORM_FIELDS = [ - (("lat",), "Latitud", "float"), - (("lon",), "Longitud", "float"), - (("timezone",), "Zona horaria", "text"), - (("ds_api_key",), "Identificador Open-Meteo", "text"), - (("units",), "Unidades", "text"), - (("lang",), "Idioma meteorológico", "text"), - (("ui_lang",), "Idioma de interfaz", "text"), - (("update_freq",), "Frecuencia forecast (segundos)", "int"), - (("fullscreen",), "Pantalla completa", "bool"), - (("12hour_disp",), "Formato 12 horas", "bool"), - (("icon_offset",), "Offset de iconos", "float"), - (("info_pause",), "Pausa info (segundos)", "int"), - (("info_delay",), "Retraso info (segundos)", "int"), - (("plugins", "daily", "enabled"), "Página diaria activa", "bool"), - (("plugins", "daily", "pause"), "Pausa diaria (segundos)", "int"), - (("plugins", "hourly", "enabled"), "Página horaria activa", "bool"), - (("plugins", "hourly", "pause"), "Pausa horaria (segundos)", "int"), - (("log_level",), "Nivel de log", "text"), + (("lat",), "lat", "float"), + (("lon",), "lon", "float"), + (("timezone",), "timezone", "text"), + (("ds_api_key",), "ds_api_key", "text"), + (("units",), "units", "text"), + (("lang",), "lang", "text"), + (("ui_lang",), "ui_lang", "text"), + (("update_freq",), "update_freq", "int"), + (("fullscreen",), "fullscreen", "bool"), + (("12hour_disp",), "12hour_disp", "bool"), + (("icon_offset",), "icon_offset", "float"), + (("info_pause",), "info_pause", "int"), + (("info_delay",), "info_delay", "int"), + (("plugins", "daily", "enabled"), "daily_enabled", "bool"), + (("plugins", "daily", "pause"), "daily_pause", "int"), + (("plugins", "hourly", "enabled"), "hourly_enabled", "bool"), + (("plugins", "hourly", "pause"), "hourly_pause", "int"), + (("log_level",), "log_level", "text"), ] diff --git a/piweatherrock/pwr_config_web.py b/piweatherrock/pwr_config_web.py index 6f9369a..e532923 100644 --- a/piweatherrock/pwr_config_web.py +++ b/piweatherrock/pwr_config_web.py @@ -31,6 +31,26 @@ "status_ok": "OK: valid configuration. The UI will apply changes automatically.", "title": "PiWeatherRock Config", "validate": "Validate configuration", + "labels": { + "lat": "Latitude", + "lon": "Longitude", + "timezone": "Timezone", + "ds_api_key": "Open-Meteo identifier", + "units": "Units", + "lang": "Weather language", + "ui_lang": "UI language", + "update_freq": "Forecast frequency (seconds)", + "fullscreen": "Fullscreen", + "12hour_disp": "12-hour format", + "icon_offset": "Icon offset", + "info_pause": "Info pause (seconds)", + "info_delay": "Info delay (seconds)", + "daily_enabled": "Daily page enabled", + "daily_pause": "Daily pause (seconds)", + "hourly_enabled": "Hourly page enabled", + "hourly_pause": "Hourly pause (seconds)", + "log_level": "Log level", + }, }, "es": { "changes_applied": "Cambios aplicados", @@ -41,6 +61,26 @@ "status_ok": "OK: configuración válida. La UI aplicará los cambios automáticamente.", "title": "PiWeatherRock Config", "validate": "Validar configuración", + "labels": { + "lat": "Latitud", + "lon": "Longitud", + "timezone": "Zona horaria", + "ds_api_key": "Identificador Open-Meteo", + "units": "Unidades", + "lang": "Idioma meteorológico", + "ui_lang": "Idioma de interfaz", + "update_freq": "Frecuencia forecast (segundos)", + "fullscreen": "Pantalla completa", + "12hour_disp": "Formato 12 horas", + "icon_offset": "Offset de iconos", + "info_pause": "Pausa info (segundos)", + "info_delay": "Retraso info (segundos)", + "daily_enabled": "Página diaria activa", + "daily_pause": "Pausa diaria (segundos)", + "hourly_enabled": "Página horaria activa", + "hourly_pause": "Pausa horaria (segundos)", + "log_level": "Nivel de log", + }, }, } @@ -63,7 +103,7 @@ def index(self, message=""): def save(self, **params): try: config = load_config(self.config_file) - for path, label, field_type in CONFIG_FORM_FIELDS: + for path, label_key, field_type in CONFIG_FORM_FIELDS: name = field_name(path) value = self._coerce_value(params.get(name), field_type) set_config_value(config, path, value) @@ -95,11 +135,12 @@ def _render_form(self, config, message=""): rows.append('
') rows.append('
{}'.format( html.escape(self._text(config, "legend")))) - for path, label, field_type in CONFIG_FORM_FIELDS: + for path, label_key, field_type in CONFIG_FORM_FIELDS: value = get_config_value(config, path) name = field_name(path) rows.append(''.format( - name=html.escape(name), label=html.escape(label))) + name=html.escape(name), + label=html.escape(self._label(config, label_key)))) if field_type == "bool": checked = " checked" if value else "" rows.append(''.format( @@ -160,6 +201,11 @@ def _text(self, config, key): language = self._language(config) return TEXT.get(language, TEXT["en"])[key] + def _label(self, config, key): + language = self._language(config) + labels = TEXT.get(language, TEXT["en"])["labels"] + return labels.get(key, TEXT["en"]["labels"][key]) + def _language(self, config): language = config.get("ui_lang", "en") if language not in TEXT: diff --git a/piweatherrock/runner.py b/piweatherrock/runner.py index 7d2a453..6e30ff3 100644 --- a/piweatherrock/runner.py +++ b/piweatherrock/runner.py @@ -150,7 +150,8 @@ def screen_switcher(self): self.h_count = 0 # Default in config.json.sample: pause for 5 minutes on info screen - if (self.enabled_weather_screens() + enabled_weather_screens = self.enabled_weather_screens() + if (enabled_weather_screens and self.non_weather_timeout > ( self.config["info_pause"] * TICKS_PER_SECOND)): self.switch_to_default_weather_screen() @@ -288,9 +289,8 @@ def switch_to_default_weather_screen(self): def switch_to_weather_screen(self, screen): if screen not in self.enabled_weather_screens(): - if self.my_weather_rock is not None: - self.my_weather_rock.log.warning( - f"Ignoring disabled weather screen: {screen}") + self.my_weather_rock.log.warning( + f"Ignoring disabled weather screen: {screen}") return self.current_screen = screen From cb191cae6a7ee1e6ce40c04c45f1adab9676df64 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 21:20:16 +0000 Subject: [PATCH 56/91] Apply final validation refinements Agent-Logs-Url: https://github.com/carloshm/PiWeatherRock/sessions/e6dad989-6a44-4a57-b03a-27cbba89ffab Co-authored-by: carloshm <2389573+carloshm@users.noreply.github.com> --- piweatherrock/config_manager.py | 2 +- piweatherrock/runner.py | 8 ++++---- piweatherrock/weather.py | 11 ++++------- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/piweatherrock/config_manager.py b/piweatherrock/config_manager.py index 5907443..f7af95d 100644 --- a/piweatherrock/config_manager.py +++ b/piweatherrock/config_manager.py @@ -254,7 +254,7 @@ def sync_signature(self): def file_signature(config_file): stat = os.stat(config_file) - mtime = getattr(stat, "st_mtime_ns", int(stat.st_mtime * 1000000000)) + mtime = getattr(stat, "st_mtime_ns", int(stat.st_mtime * 1e9)) return mtime, stat.st_size diff --git a/piweatherrock/runner.py b/piweatherrock/runner.py index 6e30ff3..233d364 100644 --- a/piweatherrock/runner.py +++ b/piweatherrock/runner.py @@ -28,7 +28,7 @@ from piweatherrock.plugin_info import PluginInfo -TICKS_PER_SECOND = 10 +LOOP_FREQUENCY_HZ = 10 class Runner: @@ -153,7 +153,7 @@ def screen_switcher(self): enabled_weather_screens = self.enabled_weather_screens() if (enabled_weather_screens and self.non_weather_timeout > ( - self.config["info_pause"] * TICKS_PER_SECOND)): + self.config["info_pause"] * LOOP_FREQUENCY_HZ)): self.switch_to_default_weather_screen() self.my_weather_rock.log.info("Switching to weather mode") else: @@ -163,13 +163,13 @@ def screen_switcher(self): # Default is to flip between 2 weather screens # for 15 minutes before showing info screen. if self.periodic_info_activation > ( - self.config["info_delay"] * TICKS_PER_SECOND): + self.config["info_delay"] * LOOP_FREQUENCY_HZ): self.current_screen = 'i' self.my_weather_rock.log.info("Switching to info mode") elif (self.periodic_info_activation % ( ((self.config["plugins"]["daily"]["pause"] * self.d_count) + (self.config["plugins"]["hourly"]["pause"] * self.h_count)) - * TICKS_PER_SECOND)) == 0: + * LOOP_FREQUENCY_HZ)) == 0: self.switch_to_next_weather_screen() # Daily Weather Display Mode diff --git a/piweatherrock/weather.py b/piweatherrock/weather.py index 4f7d3cc..d7d4996 100644 --- a/piweatherrock/weather.py +++ b/piweatherrock/weather.py @@ -171,9 +171,7 @@ def get_forecast(self): lang=self.config["lang"], timezone=self.config["timezone"]) - sunset_today = datetime.datetime.fromtimestamp( - self.weather.daily[0].sunsetTime) - self.update_sun_strings(sunset_today) + self.update_sun_strings() # Only update the check time after a successful fetch self.last_update_check = time.time() @@ -209,13 +207,12 @@ def reload_config(self, new_config, changed_paths): elif config_changed(changed_paths, SUN_TIME_RELOAD_PATHS): self.update_sun_strings() - def update_sun_strings(self, sunset_today=None): + def update_sun_strings(self): if not hasattr(self.weather, "daily"): return - if sunset_today is None: - sunset_today = datetime.datetime.fromtimestamp( - self.weather.daily[0].sunsetTime) + sunset_today = datetime.datetime.fromtimestamp( + self.weather.daily[0].sunsetTime) if datetime.datetime.now() < sunset_today: index = 0 From 7e00e8e89437db2af59324855b989861887fceb7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 21:21:07 +0000 Subject: [PATCH 57/91] Clean up hot reload readability Agent-Logs-Url: https://github.com/carloshm/PiWeatherRock/sessions/e6dad989-6a44-4a57-b03a-27cbba89ffab Co-authored-by: carloshm <2389573+carloshm@users.noreply.github.com> --- piweatherrock/runner.py | 28 ++++++++++++++-------------- piweatherrock/weather.py | 9 +++++---- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/piweatherrock/runner.py b/piweatherrock/runner.py index 233d364..68c153a 100644 --- a/piweatherrock/runner.py +++ b/piweatherrock/runner.py @@ -28,7 +28,7 @@ from piweatherrock.plugin_info import PluginInfo -LOOP_FREQUENCY_HZ = 10 +LOOPS_PER_SECOND = 10 class Runner: @@ -153,7 +153,7 @@ def screen_switcher(self): enabled_weather_screens = self.enabled_weather_screens() if (enabled_weather_screens and self.non_weather_timeout > ( - self.config["info_pause"] * LOOP_FREQUENCY_HZ)): + self.config["info_pause"] * LOOPS_PER_SECOND)): self.switch_to_default_weather_screen() self.my_weather_rock.log.info("Switching to weather mode") else: @@ -163,13 +163,13 @@ def screen_switcher(self): # Default is to flip between 2 weather screens # for 15 minutes before showing info screen. if self.periodic_info_activation > ( - self.config["info_delay"] * LOOP_FREQUENCY_HZ): + self.config["info_delay"] * LOOPS_PER_SECOND): self.current_screen = 'i' self.my_weather_rock.log.info("Switching to info mode") elif (self.periodic_info_activation % ( ((self.config["plugins"]["daily"]["pause"] * self.d_count) + (self.config["plugins"]["hourly"]["pause"] * self.h_count)) - * LOOP_FREQUENCY_HZ)) == 0: + * LOOPS_PER_SECOND)) == 0: self.switch_to_next_weather_screen() # Daily Weather Display Mode @@ -308,18 +308,18 @@ def switch_to_next_weather_screen(self): return if self.current_screen == 'd' and 'h' in enabled: - self.my_weather_rock.log.info("Switching to HOURLY") - self.current_screen = 'h' - self.h_count += 1 + self.advance_weather_screen('h', "Switching to HOURLY") elif self.current_screen == 'h' and 'd' in enabled: - self.my_weather_rock.log.info("Switching to DAILY") - self.current_screen = 'd' - self.d_count += 1 + self.advance_weather_screen('d', "Switching to DAILY") elif enabled[0] == 'd': - self.my_weather_rock.log.info("Staying on DAILY") - self.current_screen = 'd' + self.advance_weather_screen('d', "Staying on DAILY") + else: + self.advance_weather_screen('h', "Staying on HOURLY") + + def advance_weather_screen(self, screen, message): + self.my_weather_rock.log.info(message) + self.current_screen = screen + if screen == 'd': self.d_count += 1 else: - self.my_weather_rock.log.info("Staying on HOURLY") - self.current_screen = 'h' self.h_count += 1 diff --git a/piweatherrock/weather.py b/piweatherrock/weather.py index d7d4996..86ff8f9 100644 --- a/piweatherrock/weather.py +++ b/piweatherrock/weather.py @@ -208,11 +208,12 @@ def reload_config(self, new_config, changed_paths): self.update_sun_strings() def update_sun_strings(self): - if not hasattr(self.weather, "daily"): + daily = getattr(self.weather, "daily", None) + if daily is None: return sunset_today = datetime.datetime.fromtimestamp( - self.weather.daily[0].sunsetTime) + daily[0].sunsetTime) if datetime.datetime.now() < sunset_today: index = 0 @@ -223,8 +224,8 @@ def update_sun_strings(self): sr_suffix = self.intl.get_text(self.ui_lang, "tomorrow") ss_suffix = self.intl.get_text(self.ui_lang, "tomorrow") - self.sunrise = self.weather.daily[index].sunriseTime - self.sunset = self.weather.daily[index].sunsetTime + self.sunrise = daily[index].sunriseTime + self.sunset = daily[index].sunsetTime if self.config["12hour_disp"]: self.sunrise_string = datetime.datetime.fromtimestamp( From 702ccfe053084defe4f3e6516075ba910f8fd2f2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 21:21:51 +0000 Subject: [PATCH 58/91] Clarify UI loop hot reload helpers Agent-Logs-Url: https://github.com/carloshm/PiWeatherRock/sessions/e6dad989-6a44-4a57-b03a-27cbba89ffab Co-authored-by: carloshm <2389573+carloshm@users.noreply.github.com> --- piweatherrock/runner.py | 9 +++++---- piweatherrock/weather.py | 3 +++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/piweatherrock/runner.py b/piweatherrock/runner.py index 68c153a..e1640cd 100644 --- a/piweatherrock/runner.py +++ b/piweatherrock/runner.py @@ -28,7 +28,7 @@ from piweatherrock.plugin_info import PluginInfo -LOOPS_PER_SECOND = 10 +UI_LOOP_FREQUENCY = 10 class Runner: @@ -153,7 +153,7 @@ def screen_switcher(self): enabled_weather_screens = self.enabled_weather_screens() if (enabled_weather_screens and self.non_weather_timeout > ( - self.config["info_pause"] * LOOPS_PER_SECOND)): + self.config["info_pause"] * UI_LOOP_FREQUENCY)): self.switch_to_default_weather_screen() self.my_weather_rock.log.info("Switching to weather mode") else: @@ -163,13 +163,13 @@ def screen_switcher(self): # Default is to flip between 2 weather screens # for 15 minutes before showing info screen. if self.periodic_info_activation > ( - self.config["info_delay"] * LOOPS_PER_SECOND): + self.config["info_delay"] * UI_LOOP_FREQUENCY): self.current_screen = 'i' self.my_weather_rock.log.info("Switching to info mode") elif (self.periodic_info_activation % ( ((self.config["plugins"]["daily"]["pause"] * self.d_count) + (self.config["plugins"]["hourly"]["pause"] * self.h_count)) - * LOOPS_PER_SECOND)) == 0: + * UI_LOOP_FREQUENCY)) == 0: self.switch_to_next_weather_screen() # Daily Weather Display Mode @@ -235,6 +235,7 @@ def check_config_reload(self): try: changed = self.config_watcher.changed_config() except ConfigError as e: + # Avoid repeated log messages for the same invalid config version. self.config_watcher.sync_signature() self.my_weather_rock.log.warning( f"Ignoring invalid config reload: {e}") diff --git a/piweatherrock/weather.py b/piweatherrock/weather.py index 86ff8f9..671c061 100644 --- a/piweatherrock/weather.py +++ b/piweatherrock/weather.py @@ -208,6 +208,9 @@ def reload_config(self, new_config, changed_paths): self.update_sun_strings() def update_sun_strings(self): + """ + Compute sunrise and sunset strings from current weather and display settings. + """ daily = getattr(self.weather, "daily", None) if daily is None: return From 3e7276991cdef5c25edcd64fec6dcb1233906b54 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 21:31:00 +0000 Subject: [PATCH 59/91] Complete required localization coverage Agent-Logs-Url: https://github.com/carloshm/PiWeatherRock/sessions/06e13156-de5a-421d-862c-91b848f83971 Co-authored-by: carloshm <2389573+carloshm@users.noreply.github.com> --- README.md | 3 +- piweatherrock/climate/openmeteo.py | 241 +++++++++++++++--- piweatherrock/config_manager.py | 11 + piweatherrock/intl/data/piweatherrock.ca.json | 34 +-- piweatherrock/intl/data/piweatherrock.en.json | 34 +-- piweatherrock/intl/data/piweatherrock.es.json | 34 +-- piweatherrock/intl/data/piweatherrock.eu.json | 34 +-- piweatherrock/intl/data/piweatherrock.gl.json | 34 +-- piweatherrock/pwr_config_web.py | 195 +++++++++----- pyproject.toml | 1 + tests/test_localization.py | 110 ++++++++ 11 files changed, 551 insertions(+), 180 deletions(-) create mode 100644 tests/test_localization.py diff --git a/README.md b/README.md index 19af8cb..88263a3 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,8 @@ Weather settings are configured in `piweatherrock/piweatherrock-config.json`: - `ds_api_key`: Identifier for the Open-Meteo request (no real API key needed). The name is a legacy reference from the Dark Sky era, kept for backward compatibility. - `lat` / `lon`: Your location coordinates. - `units`: Unit system (`si` for metric). -- `lang`: Language for weather descriptions. +- `lang`: Language for weather descriptions (`en`, `es`, `ca`, `gl`, `eu`). +- `ui_lang`: Language for UI labels (`en`, `es`, `ca`, `gl`, `eu`). - `timezone`: Your timezone (e.g., `Europe/Madrid`). - `update_freq`: How often to refresh weather data (in seconds). - `fullscreen`, `12hour_disp`, `icon_offset`: Display behavior. diff --git a/piweatherrock/climate/openmeteo.py b/piweatherrock/climate/openmeteo.py index 9879afb..9cc22d0 100644 --- a/piweatherrock/climate/openmeteo.py +++ b/piweatherrock/climate/openmeteo.py @@ -5,38 +5,217 @@ import time from pytz import timezone +WEATHER_TRANSLATIONS = { + 0: { + "en": "Clear sky", + "es": "Cielo despejado", + "ca": "Cel clar", + "gl": "Ceo despexado", + "eu": "Zeru garbia", + }, + 1: { + "en": "Mainly clear", + "es": "Mayormente despejado", + "ca": "Majoritàriament clar", + "gl": "Maiormente despexado", + "eu": "Nagusiki garbi", + }, + 2: { + "en": "Partly cloudy", + "es": "Parcialmente nublado", + "ca": "Parcialment ennuvolat", + "gl": "Parcialmente nubrado", + "eu": "Hodei batzuk", + }, + 3: { + "en": "Overcast", + "es": "Nublado", + "ca": "Ennuvolat", + "gl": "Nubrado", + "eu": "Estalita", + }, + 45: { + "en": "Fog", + "es": "Niebla", + "ca": "Boira", + "gl": "Néboa", + "eu": "Lainoa", + }, + 48: { + "en": "Depositing rime fog", + "es": "Niebla con escarcha", + "ca": "Boira gebradora", + "gl": "Néboa con xeada", + "eu": "Antzigar-lainoa", + }, + 51: { + "en": "Drizzle: Light intensity", + "es": "Llovizna ligera", + "ca": "Plugim lleuger", + "gl": "Poalla lixeira", + "eu": "Zirimiri arina", + }, + 53: { + "en": "Drizzle: Moderate intensity", + "es": "Llovizna moderada", + "ca": "Plugim moderat", + "gl": "Poalla moderada", + "eu": "Zirimiri moderatua", + }, + 55: { + "en": "Drizzle: Dense intensity", + "es": "Llovizna intensa", + "ca": "Plugim intens", + "gl": "Poalla intensa", + "eu": "Zirimiri trinkoa", + }, + 56: { + "en": "Freezing Drizzle: Light intensity", + "es": "Llovizna engelante ligera", + "ca": "Plugim gelant lleuger", + "gl": "Poalla conxelante lixeira", + "eu": "Zirimiri izozkor arina", + }, + 57: { + "en": "Freezing Drizzle: Dense intensity", + "es": "Llovizna engelante intensa", + "ca": "Plugim gelant intens", + "gl": "Poalla conxelante intensa", + "eu": "Zirimiri izozkor trinkoa", + }, + 61: { + "en": "Rain: Slight intensity", + "es": "Lluvia ligera", + "ca": "Pluja lleugera", + "gl": "Chuvia lixeira", + "eu": "Euri arina", + }, + 63: { + "en": "Rain: Moderate intensity", + "es": "Lluvia moderada", + "ca": "Pluja moderada", + "gl": "Chuvia moderada", + "eu": "Euri moderatua", + }, + 65: { + "en": "Rain: Heavy intensity", + "es": "Lluvia intensa", + "ca": "Pluja intensa", + "gl": "Chuvia intensa", + "eu": "Euri handia", + }, + 66: { + "en": "Freezing Rain: Light intensity", + "es": "Lluvia engelante ligera", + "ca": "Pluja gelant lleugera", + "gl": "Chuvia conxelante lixeira", + "eu": "Euri izozkor arina", + }, + 67: { + "en": "Freezing Rain: Heavy intensity", + "es": "Lluvia engelante intensa", + "ca": "Pluja gelant intensa", + "gl": "Chuvia conxelante intensa", + "eu": "Euri izozkor handia", + }, + 71: { + "en": "Snow fall: Slight intensity", + "es": "Nevada ligera", + "ca": "Nevada lleugera", + "gl": "Nevada lixeira", + "eu": "Elur arina", + }, + 73: { + "en": "Snow fall: Moderate intensity", + "es": "Nevada moderada", + "ca": "Nevada moderada", + "gl": "Nevada moderada", + "eu": "Elur moderatua", + }, + 75: { + "en": "Snow fall: Heavy intensity", + "es": "Nevada intensa", + "ca": "Nevada intensa", + "gl": "Nevada intensa", + "eu": "Elur handia", + }, + 77: { + "en": "Snow grains", + "es": "Granos de nieve", + "ca": "Grans de neu", + "gl": "Grans de neve", + "eu": "Elur-aleak", + }, + 80: { + "en": "Rain showers: Slight intensity", + "es": "Chubascos ligeros", + "ca": "Ruixats lleugers", + "gl": "Chuvascos lixeiros", + "eu": "Zaparrada arinak", + }, + 81: { + "en": "Rain showers: Moderate intensity", + "es": "Chubascos moderados", + "ca": "Ruixats moderats", + "gl": "Chuvascos moderados", + "eu": "Zaparrada moderatuak", + }, + 82: { + "en": "Rain showers: Violent intensity", + "es": "Chubascos fuertes", + "ca": "Ruixats forts", + "gl": "Chuvascos fortes", + "eu": "Zaparrada handiak", + }, + 85: { + "en": "Snow showers: Slight intensity", + "es": "Chubascos de nieve ligeros", + "ca": "Ruixats de neu lleugers", + "gl": "Chuvascos de neve lixeiros", + "eu": "Elur-zaparrada arinak", + }, + 86: { + "en": "Snow showers: Heavy intensity", + "es": "Chubascos de nieve intensos", + "ca": "Ruixats de neu intensos", + "gl": "Chuvascos de neve intensos", + "eu": "Elur-zaparrada handiak", + }, + 95: { + "en": "Thunderstorm: Slight or moderate", + "es": "Tormenta eléctrica", + "ca": "Tempesta elèctrica", + "gl": "Treboada", + "eu": "Ekaitza", + }, + 96: { + "en": "Thunderstorm with slight hail", + "es": "Tormenta eléctrica con granizo ligero", + "ca": "Tempesta amb calamarsa lleugera", + "gl": "Treboada con sarabia lixeira", + "eu": "Ekaitza txingor arinarekin", + }, + 99: { + "en": "Thunderstorm with heavy hail", + "es": "Tormenta eléctrica con granizo intenso", + "ca": "Tempesta amb calamarsa intensa", + "gl": "Treboada con sarabia intensa", + "eu": "Ekaitza txingor handiarekin", + }, +} + +UNKNOWN_WEATHER = { + "en": "Unknown", + "es": "Desconocido", + "ca": "Desconegut", + "gl": "Descoñecido", + "eu": "Ezezaguna", +} + + def get_weather_translations(lang, wmocode): - weather_translations = { - 0: {"en": "Clear sky", "es": "Cielo despejado"}, - 1: {"en": "Mainly clear", "es": "Mayormente despejado"}, - 2: {"en": "Partly cloudy", "es": "Parcialmente nublado"}, - 3: {"en": "Overcast", "es": "Nublado"}, - 45: {"en": "Fog", "es": "Neblina"}, - 48: {"en": "Depositing rime fog", "es": "Niebla de escarcha"}, - 51: {"en": "Drizzle: Light intensity", "es": "Chispeo ligero"}, - 53: {"en": "Drizzle: Moderate intensity", "es": "Chispeo moderado"}, - 55: {"en": "Drizzle: Dense intensity", "es": "Chispeo intenso"}, - 56: {"en": "Freezing Drizzle: Light intensity", "es": "Llovizna engelante"}, - 57: {"en": "Freezing Drizzle: Dense intensity", "es": "Llovizna engelante intensa"}, - 61: {"en": "Rain: Slight intensity", "es": "Chubascos"}, - 63: {"en": "Rain: Moderate intensity", "es": "Chubascos"}, - 65: {"en": "Rain: Heavy intensity", "es": "Lluvia intensa"}, - 66: {"en": "Freezing Rain: Light intensity", "es": "Lluvia engelante"}, - 67: {"en": "Freezing Rain: Heavy intensity", "es": "Lluvia engelante intensa"}, - 71: {"en": "Snow fall: Slight intensity", "es": "Nevada"}, - 73: {"en": "Snow fall: Moderate intensity", "es": "Nevada densa"}, - 75: {"en": "Snow fall: Heavy intensity", "es": "Nevada intensa"}, - 77: {"en": "Snow grains", "es": "Granos de nieve"}, - 80: {"en": "Rain showers: Slight intensity", "es": "Chubascos"}, - 81: {"en": "Rain showers: Moderate intensity", "es": "Lluvia moderada"}, - 82: {"en": "Rain showers: Violent intensity", "es": "Lluvia fuerte"}, - 85: {"en": "Snow showers: Slight intensity", "es": "Nevadas"}, - 86: {"en": "Snow showers: Heavy intensity", "es": "Nevadas intensa"}, - 95: {"en": "Thunderstorm: Slight or moderate", "es": "Tormenta eléctrica"}, - 96: {"en": "Thunderstorm with slight hail", "es": "Tormenta eléctrica con granizo"}, - 99: {"en": "Thunderstorm with heavy hail", "es": "Tormenta eléctrica con granizado intenso"} - } - return weather_translations.get(wmocode, {}).get(lang, "Unknown" if lang == "en" else "Desconocido") + return WEATHER_TRANSLATIONS.get(wmocode, {}).get( + lang, UNKNOWN_WEATHER.get(lang, UNKNOWN_WEATHER["en"])) def get_darksky_icon(wmocode): icon_map = { diff --git a/piweatherrock/config_manager.py b/piweatherrock/config_manager.py index f7af95d..2d09693 100644 --- a/piweatherrock/config_manager.py +++ b/piweatherrock/config_manager.py @@ -36,6 +36,8 @@ class ConfigError(Exception): "pause": int, } +SUPPORTED_LANGUAGES = ("en", "es", "ca", "gl", "eu") + WEATHER_RELOAD_PATHS = { ("ds_api_key",), ("lat",), @@ -136,6 +138,8 @@ def validate_config(config): _validate_positive_int(config, "update_freq", errors) _validate_positive_int(config, "info_pause", errors) _validate_positive_int(config, "info_delay", errors) + _validate_language(config, "lang", errors) + _validate_language(config, "ui_lang", errors) if isinstance(plugins, dict): for plugin_name in ("daily", "hourly"): @@ -281,6 +285,13 @@ def _validate_positive_int(config, key, errors, display_name=None): display_name or key)) +def _validate_language(config, key, errors): + value = config.get(key) + if isinstance(value, str) and value not in SUPPORTED_LANGUAGES: + errors.append("Field '{}' must be one of: {}".format( + key, ", ".join(SUPPORTED_LANGUAGES))) + + def _collect_diff(path, old_value, new_value, changed): if isinstance(old_value, dict) and isinstance(new_value, dict): keys = set(old_value.keys()) | set(new_value.keys()) diff --git a/piweatherrock/intl/data/piweatherrock.ca.json b/piweatherrock/intl/data/piweatherrock.ca.json index f3b7255..010d901 100644 --- a/piweatherrock/intl/data/piweatherrock.ca.json +++ b/piweatherrock/intl/data/piweatherrock.ca.json @@ -1,19 +1,19 @@ { - "ca":{ - "feels_like": "Sensació tèrmica:", - "wind": "Vent:", - "humidity": "Humitat:", - "umbrella": "¡Agafa el paraigües!", - "no_umbrella": "Avui no agafis el paraigües", - "today": "avui", - "powered_by": "Weather rock gràcies a Dark Sky", - "tonight": "aquesta nit", - "tomorrow": "demà", - "check_at": "Part meteorològic de les", - "sunrise": "Alba: %{sunrise}", - "sunset": "Posta de sol: %{sunset}", - "sunrise_at": "fa de dia a %{hour} hrs %{minute} min", - "sunset_at": "Ocàs a %{hour} hrs %{minute} min", - "daylight": "Llum de dia: %{hour} hrs %{minute} min" - } + "ca": { + "feels_like": "Sensació tèrmica:", + "wind": "Vent:", + "humidity": "Humitat:", + "umbrella": "Agafa el paraigua!", + "no_umbrella": "Avui no cal paraigua.", + "today": "avui", + "powered_by": "Weather rock gràcies a Open-Meteo", + "tonight": "aquesta nit", + "tomorrow": "demà", + "check_at": "Part meteorològic de les", + "sunrise": "Sortida del sol: %{sunrise}", + "sunset": "Posta de sol: %{sunset}", + "sunrise_at": "Sortida del sol en %{hour} h %{minute} min", + "sunset_at": "Posta de sol en %{hour} h %{minute} min", + "daylight": "Llum de dia: %{hour} h %{minute} min" + } } diff --git a/piweatherrock/intl/data/piweatherrock.en.json b/piweatherrock/intl/data/piweatherrock.en.json index 54de593..51e17fb 100644 --- a/piweatherrock/intl/data/piweatherrock.en.json +++ b/piweatherrock/intl/data/piweatherrock.en.json @@ -1,19 +1,19 @@ { - "en": { - "feels_like": "Feels Like:", - "wind":"Wind:", - "humidity":"Humidity:", - "umbrella":"Grab your umbrella!", - "no_umbrella":"No umbrella needed today.", - "today":"today", - "powered_by":"A weather rock powered by Dark Sky", - "tonight":"tonight", - "tomorrow":"tomorrow", - "check_at":"Weather checked at", - "sunrise":"Sunrise: %{sunrise}", - "sunset":"Sunset: %{sunset}", - "sunrise_at":"Sunrise in %{hour} hrs %{minute} min", - "sunset_at":"Sunset in %{hour} hrs %{minute} min", - "daylight":"Daylight: %{hour} hrs %{minute} min" - } + "en": { + "feels_like": "Feels Like:", + "wind": "Wind:", + "humidity": "Humidity:", + "umbrella": "Grab your umbrella!", + "no_umbrella": "No umbrella needed today.", + "today": "today", + "powered_by": "A weather rock powered by Open-Meteo", + "tonight": "tonight", + "tomorrow": "tomorrow", + "check_at": "Weather checked at", + "sunrise": "Sunrise: %{sunrise}", + "sunset": "Sunset: %{sunset}", + "sunrise_at": "Sunrise in %{hour} hrs %{minute} min", + "sunset_at": "Sunset in %{hour} hrs %{minute} min", + "daylight": "Daylight: %{hour} hrs %{minute} min" + } } diff --git a/piweatherrock/intl/data/piweatherrock.es.json b/piweatherrock/intl/data/piweatherrock.es.json index cd4be14..763050d 100644 --- a/piweatherrock/intl/data/piweatherrock.es.json +++ b/piweatherrock/intl/data/piweatherrock.es.json @@ -1,19 +1,19 @@ { - "es": { - "feels_like": "Sensación térmica:", - "wind":"Viento:", - "humidity":"Humedad absoluta:", - "umbrella":"¡Rayos y centellas!", - "no_umbrella":"Hoy no cojas el paraguas", - "today":"hoy", - "powered_by":"Weather rock gracias a Dark Sky", - "tonight":"esta noche", - "tomorrow":"mañana", - "check_at":"Parte meteorológico de las", - "sunrise":"Amanecer: %{sunrise}", - "sunset":"Puesta de sol: %{sunset}", - "sunrise_at":"Amanece en %{hour} hrs %{minute} min", - "sunset_at":"Ocaso en %{hour} hrs %{minute} min", - "daylight":"Luz de día: %{hour} hrs %{minute} min" - } + "es": { + "feels_like": "Sensación térmica:", + "wind": "Viento:", + "humidity": "Humedad:", + "umbrella": "¡Coge el paraguas!", + "no_umbrella": "Hoy no hace falta paraguas.", + "today": "hoy", + "powered_by": "Weather rock gracias a Open-Meteo", + "tonight": "esta noche", + "tomorrow": "mañana", + "check_at": "Parte meteorológico de las", + "sunrise": "Amanecer: %{sunrise}", + "sunset": "Puesta de sol: %{sunset}", + "sunrise_at": "Amanece en %{hour} hrs %{minute} min", + "sunset_at": "Ocaso en %{hour} hrs %{minute} min", + "daylight": "Luz de día: %{hour} hrs %{minute} min" + } } diff --git a/piweatherrock/intl/data/piweatherrock.eu.json b/piweatherrock/intl/data/piweatherrock.eu.json index 263f6da..f0a8836 100644 --- a/piweatherrock/intl/data/piweatherrock.eu.json +++ b/piweatherrock/intl/data/piweatherrock.eu.json @@ -1,19 +1,19 @@ { - "eu":{ - "feels_like": "Sentitzen da:", - "wind": "Haizea:", - "humidity": "Hezetasuna:", - "umbrella": "Hartu aterkia!", - "no_umbrella": "Gaur ez hartu aterkia", - "today": "gaur", - "powered_by": "Weather rock Dark Sky-ri esker", - "tonight": "gaur gauean", - "tomorrow": "bihar", - "check_at": "Eguraldiaren iragarpena", - "sunrise": "Egunsentia: %{sunrise}", - "sunset": "Ilunabarra: %{sunset}", - "sunrise_at": "Egunsentia %{hour} hrs %{minute} min", - "sunset_at": "Ilunabarra %{hour} hrs %{minute} min", - "daylight": "Eguneko argia: %{hour} hrs %{minute} min" - } + "eu": { + "feels_like": "Sentsazioa:", + "wind": "Haizea:", + "humidity": "Hezetasuna:", + "umbrella": "Hartu aterkia!", + "no_umbrella": "Gaur ez da aterkirik behar.", + "today": "gaur", + "powered_by": "Weather rock Open-Meteori esker", + "tonight": "gaur gauean", + "tomorrow": "bihar", + "check_at": "Eguraldia egiaztatua", + "sunrise": "Egunsentia: %{sunrise}", + "sunset": "Ilunabarra: %{sunset}", + "sunrise_at": "Egunsentia %{hour} h %{minute} min barru", + "sunset_at": "Ilunabarra %{hour} h %{minute} min barru", + "daylight": "Eguneko argia: %{hour} h %{minute} min" + } } diff --git a/piweatherrock/intl/data/piweatherrock.gl.json b/piweatherrock/intl/data/piweatherrock.gl.json index a77d23b..6e25b10 100644 --- a/piweatherrock/intl/data/piweatherrock.gl.json +++ b/piweatherrock/intl/data/piweatherrock.gl.json @@ -1,19 +1,19 @@ { - "gl":{ - "feels_like": "Refrixeración do vento:", - "wind": "Vento:", - "moist": "Humidade:", - "umbrella": "Agarra o paraugas!", - "no_umbrella": "Non collas o paraugas hoxe", - "today": "hoxe", - "powered_by": "O tempo é rockeiro grazas a Dark Sky", - "tonight": "esta noite", - "mañá": "mañá", - "check_at": "Informe meteorolóxico do", - "sunrise": "Amanecer: %{sunrise}", - "sunset": "Atardecer: %{sunset}", - "sunrise_at": "Amencer en %{hour} hrs %{minute} min", - "sunset_at": "Atardecer en %{hour} hrs %{minute} min", - "daylight": "Luz do día: %{hour} hrs %{minute} min" - } + "gl": { + "feels_like": "Sensación térmica:", + "wind": "Vento:", + "humidity": "Humidade:", + "umbrella": "Colle o paraugas!", + "no_umbrella": "Hoxe non fai falta paraugas.", + "today": "hoxe", + "powered_by": "Weather rock grazas a Open-Meteo", + "tonight": "esta noite", + "tomorrow": "mañá", + "check_at": "Parte meteorolóxico das", + "sunrise": "Amencer: %{sunrise}", + "sunset": "Solpor: %{sunset}", + "sunrise_at": "Amence en %{hour} h %{minute} min", + "sunset_at": "Solpor en %{hour} h %{minute} min", + "daylight": "Luz do día: %{hour} h %{minute} min" + } } diff --git a/piweatherrock/pwr_config_web.py b/piweatherrock/pwr_config_web.py index e532923..a09a19b 100644 --- a/piweatherrock/pwr_config_web.py +++ b/piweatherrock/pwr_config_web.py @@ -15,74 +15,143 @@ field_name, get_config_value, load_config, + SUPPORTED_LANGUAGES, set_config_value, validate_config, write_config_atomic, ) -TEXT = { - "en": { - "changes_applied": "Changes applied", - "config_error": "Configuration error", - "error": "Error", - "legend": "PiWeatherRock configuration", - "save": "Save changes", - "status_ok": "OK: valid configuration. The UI will apply changes automatically.", - "title": "PiWeatherRock Config", - "validate": "Validate configuration", - "labels": { - "lat": "Latitude", - "lon": "Longitude", - "timezone": "Timezone", - "ds_api_key": "Open-Meteo identifier", - "units": "Units", - "lang": "Weather language", - "ui_lang": "UI language", - "update_freq": "Forecast frequency (seconds)", - "fullscreen": "Fullscreen", - "12hour_disp": "12-hour format", - "icon_offset": "Icon offset", - "info_pause": "Info pause (seconds)", - "info_delay": "Info delay (seconds)", - "daily_enabled": "Daily page enabled", - "daily_pause": "Daily pause (seconds)", - "hourly_enabled": "Hourly page enabled", - "hourly_pause": "Hourly pause (seconds)", - "log_level": "Log level", - }, - }, - "es": { - "changes_applied": "Cambios aplicados", - "config_error": "Error de configuración", - "error": "Error", - "legend": "Configuración PiWeatherRock", - "save": "Guardar cambios", - "status_ok": "OK: configuración válida. La UI aplicará los cambios automáticamente.", - "title": "PiWeatherRock Config", - "validate": "Validar configuración", - "labels": { - "lat": "Latitud", - "lon": "Longitud", - "timezone": "Zona horaria", - "ds_api_key": "Identificador Open-Meteo", - "units": "Unidades", - "lang": "Idioma meteorológico", - "ui_lang": "Idioma de interfaz", - "update_freq": "Frecuencia forecast (segundos)", - "fullscreen": "Pantalla completa", - "12hour_disp": "Formato 12 horas", - "icon_offset": "Offset de iconos", - "info_pause": "Pausa info (segundos)", - "info_delay": "Retraso info (segundos)", - "daily_enabled": "Página diaria activa", - "daily_pause": "Pausa diaria (segundos)", - "hourly_enabled": "Página horaria activa", - "hourly_pause": "Pausa horaria (segundos)", - "log_level": "Nivel de log", - }, - }, -} +TEXT = {'en': {'changes_applied': 'Changes applied', + 'config_error': 'Configuration error', + 'error': 'Error', + 'legend': 'PiWeatherRock configuration', + 'save': 'Save changes', + 'status_ok': 'OK: valid configuration. The UI will apply changes automatically.', + 'title': 'PiWeatherRock Config', + 'validate': 'Validate configuration', + 'labels': {'lat': 'Latitude', + 'lon': 'Longitude', + 'timezone': 'Timezone', + 'ds_api_key': 'Open-Meteo identifier', + 'units': 'Units', + 'lang': 'Weather language', + 'ui_lang': 'UI language', + 'update_freq': 'Forecast frequency (seconds)', + 'fullscreen': 'Fullscreen', + '12hour_disp': '12-hour format', + 'icon_offset': 'Icon offset', + 'info_pause': 'Info pause (seconds)', + 'info_delay': 'Info delay (seconds)', + 'daily_enabled': 'Daily page enabled', + 'daily_pause': 'Daily pause (seconds)', + 'hourly_enabled': 'Hourly page enabled', + 'hourly_pause': 'Hourly pause (seconds)', + 'log_level': 'Log level'}}, + 'es': {'changes_applied': 'Cambios aplicados', + 'config_error': 'Error de configuración', + 'error': 'Error', + 'legend': 'Configuración PiWeatherRock', + 'save': 'Guardar cambios', + 'status_ok': 'OK: configuración válida. La UI aplicará los cambios automáticamente.', + 'title': 'Configuración de PiWeatherRock', + 'validate': 'Validar configuración', + 'labels': {'lat': 'Latitud', + 'lon': 'Longitud', + 'timezone': 'Zona horaria', + 'ds_api_key': 'Identificador Open-Meteo', + 'units': 'Unidades', + 'lang': 'Idioma meteorológico', + 'ui_lang': 'Idioma de interfaz', + 'update_freq': 'Frecuencia del pronóstico (segundos)', + 'fullscreen': 'Pantalla completa', + '12hour_disp': 'Formato de 12 horas', + 'icon_offset': 'Desplazamiento de iconos', + 'info_pause': 'Pausa de información (segundos)', + 'info_delay': 'Retraso de información (segundos)', + 'daily_enabled': 'Página diaria activada', + 'daily_pause': 'Pausa diaria (segundos)', + 'hourly_enabled': 'Página horaria activada', + 'hourly_pause': 'Pausa horaria (segundos)', + 'log_level': 'Nivel de log'}}, + 'ca': {'changes_applied': 'Canvis aplicats', + 'config_error': 'Error de configuració', + 'error': 'Error', + 'legend': 'Configuració de PiWeatherRock', + 'save': 'Desa els canvis', + 'status_ok': 'OK: configuració vàlida. La UI aplicarà els canvis automàticament.', + 'title': 'Configuració de PiWeatherRock', + 'validate': 'Valida la configuració', + 'labels': {'lat': 'Latitud', + 'lon': 'Longitud', + 'timezone': 'Zona horària', + 'ds_api_key': 'Identificador Open-Meteo', + 'units': 'Unitats', + 'lang': 'Idioma meteorològic', + 'ui_lang': 'Idioma de la interfície', + 'update_freq': 'Freqüència de la previsió (segons)', + 'fullscreen': 'Pantalla completa', + '12hour_disp': 'Format de 12 hores', + 'icon_offset': 'Desplaçament de les icones', + 'info_pause': 'Pausa d’informació (segons)', + 'info_delay': 'Retard d’informació (segons)', + 'daily_enabled': 'Pàgina diària activada', + 'daily_pause': 'Pausa diària (segons)', + 'hourly_enabled': 'Pàgina horària activada', + 'hourly_pause': 'Pausa horària (segons)', + 'log_level': 'Nivell de log'}}, + 'gl': {'changes_applied': 'Cambios aplicados', + 'config_error': 'Erro de configuración', + 'error': 'Erro', + 'legend': 'Configuración de PiWeatherRock', + 'save': 'Gardar cambios', + 'status_ok': 'OK: configuración válida. A UI aplicará os cambios automaticamente.', + 'title': 'Configuración de PiWeatherRock', + 'validate': 'Validar configuración', + 'labels': {'lat': 'Latitude', + 'lon': 'Lonxitude', + 'timezone': 'Zona horaria', + 'ds_api_key': 'Identificador Open-Meteo', + 'units': 'Unidades', + 'lang': 'Idioma meteorolóxico', + 'ui_lang': 'Idioma da interface', + 'update_freq': 'Frecuencia da predición (segundos)', + 'fullscreen': 'Pantalla completa', + '12hour_disp': 'Formato de 12 horas', + 'icon_offset': 'Desprazamento das iconas', + 'info_pause': 'Pausa de información (segundos)', + 'info_delay': 'Retardo de información (segundos)', + 'daily_enabled': 'Páxina diaria activada', + 'daily_pause': 'Pausa diaria (segundos)', + 'hourly_enabled': 'Páxina horaria activada', + 'hourly_pause': 'Pausa horaria (segundos)', + 'log_level': 'Nivel de log'}}, + 'eu': {'changes_applied': 'Aldaketak aplikatu dira', + 'config_error': 'Konfigurazio-errorea', + 'error': 'Errorea', + 'legend': 'PiWeatherRock konfigurazioa', + 'save': 'Gorde aldaketak', + 'status_ok': 'OK: konfigurazioa baliozkoa da. UIak aldaketak automatikoki aplikatuko ditu.', + 'title': 'PiWeatherRock konfigurazioa', + 'validate': 'Balidatu konfigurazioa', + 'labels': {'lat': 'Latitudea', + 'lon': 'Longitudea', + 'timezone': 'Ordu-zona', + 'ds_api_key': 'Open-Meteo identifikatzailea', + 'units': 'Unitateak', + 'lang': 'Eguraldiaren hizkuntza', + 'ui_lang': 'Interfazearen hizkuntza', + 'update_freq': 'Iragarpenaren maiztasuna (segundoak)', + 'fullscreen': 'Pantaila osoa', + '12hour_disp': '12 orduko formatua', + 'icon_offset': 'Ikonoen desplazamendua', + 'info_pause': 'Informazio-pausa (segundoak)', + 'info_delay': 'Informazio-atzerapena (segundoak)', + 'daily_enabled': 'Eguneko orria gaituta', + 'daily_pause': 'Eguneko pausa (segundoak)', + 'hourly_enabled': 'Orduko orria gaituta', + 'hourly_pause': 'Orduko pausa (segundoak)', + 'log_level': 'Log-maila'}}} class ConfigWebApp: @@ -208,7 +277,7 @@ def _label(self, config, key): def _language(self, config): language = config.get("ui_lang", "en") - if language not in TEXT: + if language not in SUPPORTED_LANGUAGES: language = language.split("_")[0].split("-")[0] if language not in TEXT: language = "en" diff --git a/pyproject.toml b/pyproject.toml index 6ff1b66..bc37ae4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,5 +42,6 @@ include = ["piweatherrock*"] piweatherrock = [ "config.json-sample", "data/*.json", + "intl/data/*.json", "plugin_weather_common/icons/**/*", ] diff --git a/tests/test_localization.py b/tests/test_localization.py new file mode 100644 index 0000000..3b5e3e1 --- /dev/null +++ b/tests/test_localization.py @@ -0,0 +1,110 @@ +import ast +import json +import os +import unittest + +from piweatherrock.config_manager import ConfigError, SUPPORTED_LANGUAGES, validate_config +from piweatherrock.climate.openmeteo import get_weather_translations + + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +LOCALIZATION_KEYS = { + "check_at", + "daylight", + "feels_like", + "humidity", + "no_umbrella", + "powered_by", + "sunrise", + "sunrise_at", + "sunset", + "sunset_at", + "today", + "tomorrow", + "tonight", + "umbrella", + "wind", +} + + +def _valid_config(language): + return { + "ds_api_key": "openmeteo-request-piweatherrock", + "lat": 40.299457, + "lon": -3.743399, + "units": "si", + "lang": language, + "ui_lang": language, + "timezone": "Europe/Madrid", + "fullscreen": True, + "12hour_disp": False, + "icon_offset": -23.5, + "update_freq": 900, + "info_pause": 60, + "info_delay": 900, + "plugins": { + "daily": {"pause": 60, "enabled": True}, + "hourly": {"pause": 60, "enabled": True}, + }, + "log_level": "INFO", + } + + +def _web_text(): + path = os.path.join(REPO_ROOT, "piweatherrock", "pwr_config_web.py") + with open(path, "r", encoding="utf-8") as f: + module = ast.parse(f.read()) + for node in module.body: + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name) and target.id == "TEXT": + return ast.literal_eval(node.value) + raise AssertionError("TEXT dictionary not found") + + +class LocalizationTest(unittest.TestCase): + def test_required_languages_have_complete_ui_literals(self): + data_dir = os.path.join(REPO_ROOT, "piweatherrock", "intl", "data") + for language in SUPPORTED_LANGUAGES: + path = os.path.join(data_dir, "piweatherrock.{}.json".format(language)) + with self.subTest(language=language): + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + self.assertEqual(set(data), {language}) + self.assertEqual(set(data[language]), LOCALIZATION_KEYS) + + def test_required_languages_are_valid_config_values(self): + for language in SUPPORTED_LANGUAGES: + with self.subTest(language=language): + self.assertTrue(validate_config(_valid_config(language))) + + def test_unsupported_languages_are_rejected(self): + config = _valid_config("fr") + with self.assertRaises(ConfigError): + validate_config(config) + + def test_required_languages_have_weather_translations(self): + for language in SUPPORTED_LANGUAGES: + with self.subTest(language=language): + text = get_weather_translations(language, 0) + self.assertNotIn(text, ("Unknown", "Desconocido")) + self.assertNotEqual(text, "") + + def test_web_config_text_covers_required_languages(self): + text = _web_text() + en_keys = set(text["en"]) + en_label_keys = set(text["en"]["labels"]) + self.assertEqual(set(text), set(SUPPORTED_LANGUAGES)) + for language in SUPPORTED_LANGUAGES: + with self.subTest(language=language): + self.assertEqual(set(text[language]), en_keys) + self.assertEqual(set(text[language]["labels"]), en_label_keys) + + def test_package_data_includes_localization_files(self): + path = os.path.join(REPO_ROOT, "pyproject.toml") + with open(path, "r", encoding="utf-8") as f: + self.assertIn('"intl/data/*.json"', f.read()) + + +if __name__ == "__main__": + unittest.main() From d6e3896a31d557164c23a4d93c2da7fe348e93b6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 22:11:41 +0000 Subject: [PATCH 60/91] Add visual config app documentation Agent-Logs-Url: https://github.com/carloshm/PiWeatherRock/sessions/71504c37-bbba-4eac-b639-89607eb32365 Co-authored-by: carloshm <2389573+carloshm@users.noreply.github.com> --- README.md | 1 + docs/README.md | 70 ++++++++++++++++++++++++ docs/images/aplicacion-configuracion.svg | 67 +++++++++++++++++++++++ 3 files changed, 138 insertions(+) create mode 100644 docs/images/aplicacion-configuracion.svg diff --git a/README.md b/README.md index 9c545f8..aeb5006 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ pwr-config-web -c ./piweatherrock/piweatherrock-config.json The config UI binds to `127.0.0.1:8888` by default. Use `--host` and `--port` only when you intentionally want to expose it elsewhere on your network. +See the expanded visual guide in [`docs/README.md`](docs/README.md#aplicaci%C3%B3n-web-de-configuraci%C3%B3n). ## Installation diff --git a/docs/README.md b/docs/README.md index e6d2034..6119729 100644 --- a/docs/README.md +++ b/docs/README.md @@ -40,6 +40,76 @@ pwr-ui -c ./piweatherrock/piweatherrock-config.json También queda disponible `pwr-config-upgrade` para actualizar configuraciones antiguas. +## Aplicación web de configuración + +PiWeatherRock incluye una aplicación web local para editar el mismo fichero JSON que usa la interfaz principal. Es útil para ajustar ubicación, idioma, zona horaria y rotación de pantallas sin modificar el archivo a mano. + +![Vista representativa de la aplicación web de configuración](images/aplicacion-configuracion.svg) + +### Arranque rápido + +1. Instala el paquete y crea la configuración inicial desde la plantilla. +2. Ejecuta la aplicación de configuración indicando el fichero JSON: + + ```bash + pwr-config-web -c ./piweatherrock/piweatherrock-config.json + ``` + +3. Abre el navegador en `http://127.0.0.1:8888`. +4. Cambia los valores necesarios y pulsa **Guardar cambios**. +5. Si `pwr-ui` está en ejecución, aplicará automáticamente los cambios válidos al detectar la actualización del JSON. + +### Qué se puede configurar + +La pantalla web expone los campos principales definidos para `piweatherrock/piweatherrock-config.json`: + +- **Ubicación:** `lat`, `lon` y `timezone`. +- **Open-Meteo:** `ds_api_key`, mantenido por compatibilidad como identificador heredado. +- **Unidades e idiomas:** `units`, `lang` y `ui_lang`. +- **Actualización meteorológica:** `update_freq`, en segundos. +- **Presentación:** `fullscreen`, `12hour_disp` e `icon_offset`. +- **Pantalla de información:** `info_pause` e `info_delay`. +- **Rotación diaria y horaria:** `plugins.daily.enabled`, `plugins.daily.pause`, `plugins.hourly.enabled` y `plugins.hourly.pause`. +- **Diagnóstico:** `log_level`. + +### Validación y guardado + +Al guardar, la aplicación carga el JSON actual, convierte los valores del formulario al tipo esperado, valida la configuración y escribe el fichero de forma atómica. Si ya existía un fichero de configuración, se conserva una copia con sufijo `.bak`. + +El enlace **Validar configuración** abre `/status` y devuelve: + +- `OK: configuración válida...` cuando el JSON cumple los requisitos. +- `ERROR: ...` con código HTTP 400 si falta un campo, hay un tipo incorrecto o algún valor está fuera de rango. + +Si el JSON queda inválido mientras `pwr-ui` está funcionando, la interfaz principal mantiene la configuración activa anterior y registra el error en lugar de aplicar el cambio defectuoso. + +### Seguridad de red + +Por defecto, la aplicación escucha solo en `127.0.0.1:8888`, es decir, únicamente desde la propia máquina: + +```bash +pwr-config-web -c ./piweatherrock/piweatherrock-config.json +``` + +Solo usa `--host` si necesitas acceder desde otro equipo de tu red y entiendes el riesgo de exponer la configuración: + +```bash +pwr-config-web -c ./piweatherrock/piweatherrock-config.json --host 0.0.0.0 --port 8888 +``` + +En ese caso, limita el acceso a una red de confianza y cierra la aplicación cuando termines de configurar. + +### Flujo visual recomendado + +```text +config.json-sample + │ + ▼ +piweatherrock-config.json ──► pwr-config-web ──► Guardar / validar + │ │ + └──────────── pwr-ui detecta cambios válidos ────────────┘ +``` + ## Pantalla de previsión diaria La pantalla diaria combina la hora, la temperatura actual, el resumen meteorológico, viento, humedad y aviso de paraguas con la previsión de hoy y los tres próximos días. diff --git a/docs/images/aplicacion-configuracion.svg b/docs/images/aplicacion-configuracion.svg new file mode 100644 index 0000000..22bab2b --- /dev/null +++ b/docs/images/aplicacion-configuracion.svg @@ -0,0 +1,67 @@ + + Vista representativa de la aplicación web de configuración de PiWeatherRock + Formulario web local con campos de ubicación, zona horaria, idiomas, pantalla, rotación de páginas, botón de guardar y enlace de validación. + + + + Configuración de PiWeatherRock + 127.0.0.1:8888 + + + Ubicación y previsión + + + Latitud + + 40.4168 + + Longitud + + -3.7038 + + Zona horaria + + Europe/Madrid + + Idioma meteorológico + + es + + Idioma de interfaz + + es + + Frecuencia del pronóstico + + 900 segundos + + Pantalla y rotación + + + + + Pantalla completa + + + Formato de 12 horas + + + Desplazamiento iconos: 30 + + + + Página diaria activada · pausa 60s + + + + Página horaria activada · pausa 60s + + + Guardar cambios + Validar configuración + + + Cambios aplicados + pwr-ui recarga el JSON válido + + From 1f43b479f55dbc0e35e0eea64594cc82b0bbb302 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 22:17:26 +0000 Subject: [PATCH 61/91] Add configurable local media screen Agent-Logs-Url: https://github.com/carloshm/PiWeatherRock/sessions/c998c6bd-4110-4f02-89a1-fe764d71e750 Co-authored-by: carloshm <2389573+carloshm@users.noreply.github.com> --- README.md | 9 +- docs/README.md | 21 +- piweatherrock/config.json-sample | 12 ++ piweatherrock/config_manager.py | 100 +++++++++- piweatherrock/piweatherrock-config.json | 12 ++ piweatherrock/plugin_media/__init__.py | 251 ++++++++++++++++++++++++ piweatherrock/pwr_config_web.py | 32 ++- piweatherrock/runner.py | 131 ++++++++----- tests/test_config_manager.py | 33 ++++ 9 files changed, 535 insertions(+), 66 deletions(-) create mode 100644 piweatherrock/plugin_media/__init__.py diff --git a/README.md b/README.md index 9c545f8..ce279ef 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,12 @@ Weather settings are configured in `piweatherrock/piweatherrock-config.json`: - `timezone`: Your timezone (e.g., `Europe/Madrid`). - `update_freq`: How often to refresh weather data (in seconds). - `fullscreen`, `12hour_disp`, `icon_offset`: Display behavior. -- `info_pause`, `info_delay`, `plugins`: Page rotation behavior. +- `info_pause`, `info_delay`, `plugins`: Page rotation behavior. `plugins` + controls which screens are shown (`daily`, `hourly`, `info`, `media`) and + each screen's display time. +- `plugins.media`: Local media screen settings for images and short videos + loaded from a folder. Configure `enabled`, `pause`, `path`, `shuffle`, `fit` + (`contain`, `cover`, or `stretch`), and allowed `extensions`. PiWeatherRock automatically checks the config file while `pwr-ui` is running. Valid changes are applied without restarting the display. If the JSON is invalid, @@ -36,6 +41,8 @@ pwr-config-web -c ./piweatherrock/piweatherrock-config.json The config UI binds to `127.0.0.1:8888` by default. Use `--host` and `--port` only when you intentionally want to expose it elsewhere on your network. +The same UI exposes the screen selection, display time, and local media folder +settings. ## Installation diff --git a/docs/README.md b/docs/README.md index e6d2034..8a92e28 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,7 +4,7 @@ Esta carpeta documenta el uso actual de PiWeatherRock y complementa las instrucc ## Qué muestra la aplicación -PiWeatherRock es una interfaz de pantalla completa para Raspberry Pi u otros equipos con pantalla conectada. Consulta Open-Meteo, traduce los datos al formato interno heredado de Dark Sky y alterna entre pantallas de previsión diaria, previsión horaria e información general. +PiWeatherRock es una interfaz de pantalla completa para Raspberry Pi u otros equipos con pantalla conectada. Consulta Open-Meteo, traduce los datos al formato interno heredado de Dark Sky y alterna entre pantallas de previsión diaria, previsión horaria, información general y una pantalla opcional de medios locales. ## Instalación actual resumida @@ -58,11 +58,22 @@ La pantalla de información reduce el contenido visual para ayudar a evitar quem ![Captura de la pantalla de información](images/pantalla-informacion.svg) +## Pantalla de medios locales + +La pantalla de medios locales funciona como marco digital. Lee imágenes y vídeos cortos de una carpeta local configurada en `plugins.media.path`, los escala a la pantalla y permite elegir el modo de ajuste: + +- `contain`: muestra el archivo completo con bandas si hace falta. +- `cover`: llena toda la pantalla recortando lo necesario. +- `stretch`: ajusta al tamaño de pantalla deformando si la proporción no coincide. + +Las imágenes soportadas son `jpg`, `jpeg`, `png`, `gif` y `bmp`. Los vídeos configurados (`mp4`, `mov`, `m4v`, `avi`, `webm`) se reproducen mediante `ffmpeg` si está instalado en el sistema; si no está disponible, la pantalla muestra un aviso. Se puede abrir manualmente con la tecla `m`. + ## Controles principales - `d`: cambia a previsión diaria. - `h`: cambia a previsión horaria. - `i`: cambia a información general. +- `m`: cambia a medios locales. - `s`: guarda una captura como `screenshot.jpeg`. - `q` o Intro del teclado numérico: cierra la aplicación. @@ -76,4 +87,10 @@ Los valores se editan en `piweatherrock/piweatherrock-config.json`: - `units`: sistema de unidades; `si` usa métricas. - `fullscreen`: ejecuta en pantalla completa si es `true`. - `update_freq`: frecuencia de actualización de Open-Meteo en segundos. -- `plugins.daily` y `plugins.hourly`: activación y tiempo de permanencia de las pantallas diaria y horaria. +- `plugins.daily`, `plugins.hourly`, `plugins.info` y `plugins.media`: activación y tiempo de permanencia de cada pantalla. +- `plugins.media.path`: carpeta local desde la que se leen imágenes y vídeos cortos. +- `plugins.media.shuffle`: alterna el orden secuencial o aleatorio. +- `plugins.media.fit`: modo de ajuste (`contain`, `cover` o `stretch`). +- `plugins.media.extensions`: extensiones permitidas separadas por comas. + +La aplicación web `pwr-config-web` permite editar qué pantallas se visualizan y el tiempo de visualización de cada una. La configuración se recarga automáticamente en la UI principal cuando el JSON actualizado es válido. diff --git a/piweatherrock/config.json-sample b/piweatherrock/config.json-sample index 0e9b79c..8c01b2e 100644 --- a/piweatherrock/config.json-sample +++ b/piweatherrock/config.json-sample @@ -22,6 +22,18 @@ "hourly": { "enabled": true, "pause": 60 + }, + "info": { + "enabled": true, + "pause": 300 + }, + "media": { + "enabled": false, + "pause": 20, + "path": "/home/pi/Pictures", + "shuffle": false, + "fit": "contain", + "extensions": "jpg,jpeg,png,gif,bmp,mp4,mov,m4v,avi,webm" } } } diff --git a/piweatherrock/config_manager.py b/piweatherrock/config_manager.py index 2d09693..1f3b105 100644 --- a/piweatherrock/config_manager.py +++ b/piweatherrock/config_manager.py @@ -36,6 +36,33 @@ class ConfigError(Exception): "pause": int, } +DEFAULT_PLUGINS = { + "daily": { + "enabled": True, + "pause": 60, + }, + "hourly": { + "enabled": True, + "pause": 60, + }, + "info": { + "enabled": True, + "pause": 300, + }, + "media": { + "enabled": False, + "pause": 20, + "path": "", + "shuffle": False, + "fit": "contain", + "extensions": "jpg,jpeg,png,gif,bmp,mp4,mov,m4v,avi,webm", + }, +} + +MEDIA_IMAGE_EXTENSIONS = ("jpg", "jpeg", "png", "gif", "bmp") +MEDIA_VIDEO_EXTENSIONS = ("mp4", "mov", "m4v", "avi", "webm") +MEDIA_FIT_MODES = ("contain", "cover", "stretch") + SUPPORTED_LANGUAGES = ("en", "es", "ca", "gl", "eu") WEATHER_RELOAD_PATHS = { @@ -64,10 +91,7 @@ class ConfigError(Exception): ROTATION_RELOAD_PATHS = { ("info_pause",), ("info_delay",), - ("plugins", "daily", "pause"), - ("plugins", "hourly", "pause"), - ("plugins", "daily", "enabled"), - ("plugins", "hourly", "enabled"), + ("plugins",), } @@ -89,6 +113,14 @@ class ConfigError(Exception): (("plugins", "daily", "pause"), "daily_pause", "int"), (("plugins", "hourly", "enabled"), "hourly_enabled", "bool"), (("plugins", "hourly", "pause"), "hourly_pause", "int"), + (("plugins", "info", "enabled"), "info_enabled", "bool"), + (("plugins", "info", "pause"), "info_pause_plugin", "int"), + (("plugins", "media", "enabled"), "media_enabled", "bool"), + (("plugins", "media", "pause"), "media_pause", "int"), + (("plugins", "media", "path"), "media_path", "text"), + (("plugins", "media", "shuffle"), "media_shuffle", "bool"), + (("plugins", "media", "fit"), "media_fit", "text"), + (("plugins", "media", "extensions"), "media_extensions", "text"), (("log_level",), "log_level", "text"), ] @@ -102,8 +134,9 @@ def load_config(config_file): raise ConfigError("Could not load config file '{}': {}".format( config_file, exc)) - validate_config(config) - return config + normalized = normalize_config(config) + validate_config(normalized) + return normalized def validate_config(config): @@ -121,8 +154,9 @@ def validate_config(config): plugins = config.get("plugins") if isinstance(plugins, dict): - for plugin_name in ("daily", "hourly"): - plugin_config = plugins.get(plugin_name) + normalized_plugins = merge_defaults(plugins, DEFAULT_PLUGINS) + for plugin_name in DEFAULT_PLUGINS: + plugin_config = normalized_plugins.get(plugin_name) if not isinstance(plugin_config, dict): errors.append("Missing plugin '{}' configuration".format(plugin_name)) continue @@ -132,6 +166,8 @@ def validate_config(config): elif not _is_expected_type(plugin_config[key], expected_type): errors.append("Field plugins.{}.{} has invalid type".format( plugin_name, key)) + if plugin_name == "media": + _validate_media_plugin(plugin_config, errors) _validate_range(config, "lat", -90, 90, errors) _validate_range(config, "lon", -180, 180, errors) @@ -142,11 +178,17 @@ def validate_config(config): _validate_language(config, "ui_lang", errors) if isinstance(plugins, dict): - for plugin_name in ("daily", "hourly"): - plugin_config = plugins.get(plugin_name) + enabled_count = 0 + normalized_plugins = merge_defaults(plugins, DEFAULT_PLUGINS) + for plugin_name in DEFAULT_PLUGINS: + plugin_config = normalized_plugins.get(plugin_name) if isinstance(plugin_config, dict): _validate_positive_int(plugin_config, "pause", errors, "plugins.{}.pause".format(plugin_name)) + if plugin_config.get("enabled") is True: + enabled_count += 1 + if enabled_count == 0: + errors.append("At least one plugin must be enabled") if errors: raise ConfigError("Invalid configuration: " + "; ".join(errors)) @@ -154,6 +196,14 @@ def validate_config(config): return True +def normalize_config(config): + """Return a copy of config with current plugin defaults filled in.""" + normalized = copy.deepcopy(config) + normalized["plugins"] = merge_defaults( + normalized.get("plugins", {}), DEFAULT_PLUGINS) + return normalized + + def merge_defaults(config, default_config): """Return a copy of config with missing keys filled from default_config.""" merged = copy.deepcopy(config) @@ -167,6 +217,7 @@ def merge_defaults(config, default_config): def write_config_atomic(config_file, config, backup=True): """Validate and write config atomically, preserving the previous file.""" + config = normalize_config(config) validate_config(config) config_dir = os.path.dirname(os.path.abspath(config_file)) or "." @@ -292,6 +343,35 @@ def _validate_language(config, key, errors): key, ", ".join(SUPPORTED_LANGUAGES))) +def _validate_media_plugin(plugin_config, errors): + if not isinstance(plugin_config.get("path"), str): + errors.append("Field plugins.media.path has invalid type") + elif plugin_config.get("enabled") and not plugin_config["path"]: + errors.append("Field plugins.media.path is required when media is enabled") + elif plugin_config.get("enabled") and not os.path.isdir(plugin_config["path"]): + errors.append("Field plugins.media.path must be an existing directory") + if not isinstance(plugin_config.get("shuffle"), bool): + errors.append("Field plugins.media.shuffle has invalid type") + if not isinstance(plugin_config.get("fit"), str): + errors.append("Field plugins.media.fit has invalid type") + elif plugin_config["fit"] not in MEDIA_FIT_MODES: + errors.append("Field plugins.media.fit must be one of: {}".format( + ", ".join(MEDIA_FIT_MODES))) + if not isinstance(plugin_config.get("extensions"), str): + errors.append("Field plugins.media.extensions has invalid type") + return + configured = plugin_config["extensions"] + allowed = set(MEDIA_IMAGE_EXTENSIONS + MEDIA_VIDEO_EXTENSIONS) + for extension in _split_extensions(configured): + if extension not in allowed: + errors.append("Unsupported media extension '{}'".format(extension)) + + +def _split_extensions(value): + return [part.strip().lower().lstrip(".") + for part in value.split(",") if part.strip()] + + def _collect_diff(path, old_value, new_value, changed): if isinstance(old_value, dict) and isinstance(new_value, dict): keys = set(old_value.keys()) | set(new_value.keys()) diff --git a/piweatherrock/piweatherrock-config.json b/piweatherrock/piweatherrock-config.json index 20a6a4b..ff0277f 100644 --- a/piweatherrock/piweatherrock-config.json +++ b/piweatherrock/piweatherrock-config.json @@ -20,6 +20,18 @@ "hourly": { "pause": 60, "enabled": false + }, + "info": { + "pause": 300, + "enabled": true + }, + "media": { + "pause": 20, + "enabled": false, + "path": "/home/pi/Pictures", + "shuffle": false, + "fit": "contain", + "extensions": "jpg,jpeg,png,gif,bmp,mp4,mov,m4v,avi,webm" } }, "log_level": "INFO" diff --git a/piweatherrock/plugin_media/__init__.py b/piweatherrock/plugin_media/__init__.py new file mode 100644 index 0000000..32a0696 --- /dev/null +++ b/piweatherrock/plugin_media/__init__.py @@ -0,0 +1,251 @@ +# -*- coding: utf-8 -*- +"""Local media screen for images and short videos.""" + +import os +import random +import subprocess +import time +import math + +import pygame + +from piweatherrock.config_manager import ( + MEDIA_IMAGE_EXTENSIONS, + MEDIA_VIDEO_EXTENSIONS, +) + + +class PluginMedia: + """Displays images and short videos from a configured local folder.""" + + SCAN_INTERVAL = 30 + + def __init__(self, weather_rock): + self.config = None + self.screen = None + self.log = None + self.xmax = None + self.ymax = None + self.media_config = None + self.items = [] + self.current_index = -1 + self.current_item = None + self.current_surface = None + self.video_process = None + self.last_scan = 0 + self.signature = None + self.get_rock_values(weather_rock) + + def get_rock_values(self, weather_rock): + self.config = weather_rock.config + self.screen = weather_rock.screen + self.log = weather_rock.log + self.xmax = int(weather_rock.xmax) + self.ymax = int(weather_rock.ymax) + self.media_config = self.config["plugins"]["media"] + + def on_enter(self, weather_rock): + self.get_rock_values(weather_rock) + self._scan_if_needed(force=True) + self._next_item() + + def on_exit(self): + self._stop_video() + + def disp_media(self, weather_rock): + self.get_rock_values(weather_rock) + self._scan_if_needed() + if not self.current_item: + self._next_item() + + if not self.current_item: + self._render_message("No local media files found") + return + + path, kind = self.current_item + if kind == "image": + if self.current_surface is None: + self.current_surface = self._load_image(path) + if self.current_surface is None: + self._next_item() + return + self.screen.blit(self.current_surface, (0, 0)) + pygame.display.update() + else: + if self.video_process is None: + self.video_process = self._start_video(path) + if self.video_process is None: + self._render_message("Video playback requires ffmpeg") + return + frame = self.video_process.stdout.read(self.xmax * self.ymax * 3) + if len(frame) != self.xmax * self.ymax * 3: + self._next_item() + return + surface = pygame.image.frombuffer(frame, (self.xmax, self.ymax), "RGB") + self.screen.blit(surface, (0, 0)) + pygame.display.update() + + def _scan_if_needed(self, force=False): + signature = self._config_signature() + now = time.time() + if (not force and signature == self.signature + and now - self.last_scan < self.SCAN_INTERVAL): + return + + self.signature = signature + self.last_scan = now + media_path = self.media_config.get("path", "") + if not os.path.isdir(media_path): + self.items = [] + return + + image_ext = set(MEDIA_IMAGE_EXTENSIONS) + video_ext = set(MEDIA_VIDEO_EXTENSIONS) + configured_ext = self._configured_extensions() + items = [] + for name in sorted(os.listdir(media_path)): + full_path = os.path.join(media_path, name) + if not os.path.isfile(full_path): + continue + extension = os.path.splitext(name)[1].lower().lstrip(".") + if extension not in configured_ext: + continue + if extension in image_ext: + items.append((full_path, "image")) + elif extension in video_ext: + items.append((full_path, "video")) + + if self.media_config.get("shuffle"): + random.shuffle(items) + self.items = items + if self.current_item not in self.items: + self.current_index = -1 + self.current_item = None + self.current_surface = None + self._stop_video() + + def _configured_extensions(self): + return set( + part.strip().lower().lstrip(".") + for part in self.media_config.get("extensions", "").split(",") + if part.strip() + ) + + def _config_signature(self): + return ( + self.media_config.get("path"), + self.media_config.get("shuffle"), + self.media_config.get("fit"), + self.media_config.get("extensions"), + self.xmax, + self.ymax, + ) + + def _next_item(self): + self._stop_video() + self.current_surface = None + if not self.items: + self.current_index = -1 + self.current_item = None + return + self.current_index = (self.current_index + 1) % len(self.items) + self.current_item = self.items[self.current_index] + + def _load_image(self, path): + try: + image = pygame.image.load(path).convert() + return self._fit_surface(image) + except Exception: + self.log.exception("Could not load media image %s", path) + return None + + def _fit_surface(self, surface): + fit = self.media_config.get("fit", "contain") + width, height = surface.get_size() + if fit == "stretch": + return pygame.transform.smoothscale(surface, (self.xmax, self.ymax)) + + scale = max(self.xmax / width, self.ymax / height) + if fit == "contain": + scale = min(self.xmax / width, self.ymax / height) + if fit == "cover": + new_size = ( + max(self.xmax, int(math.ceil(width * scale))), + max(self.ymax, int(math.ceil(height * scale))), + ) + else: + new_size = (max(1, int(width * scale)), max(1, int(height * scale))) + scaled = pygame.transform.smoothscale(surface, new_size) + + if fit == "cover": + left = max(0, int((new_size[0] - self.xmax) / 2)) + top = max(0, int((new_size[1] - self.ymax) / 2)) + return scaled.subsurface((left, top, self.xmax, self.ymax)).copy() + + canvas = pygame.Surface((self.xmax, self.ymax)) + canvas.fill((0, 0, 0)) + left = int((self.xmax - new_size[0]) / 2) + top = int((self.ymax - new_size[1]) / 2) + canvas.blit(scaled, (left, top)) + return canvas + + def _start_video(self, path): + fit = self.media_config.get("fit", "contain") + if fit == "cover": + video_filter = ( + "scale={0}:{1}:force_original_aspect_ratio=increase," + "crop={0}:{1}" + ).format(self.xmax, self.ymax) + elif fit == "stretch": + video_filter = "scale={}:{}".format(self.xmax, self.ymax) + else: + video_filter = ( + "scale={0}:{1}:force_original_aspect_ratio=decrease," + "pad={0}:{1}:(ow-iw)/2:(oh-ih)/2:black" + ).format(self.xmax, self.ymax) + + command = [ + "ffmpeg", + "-hide_banner", + "-loglevel", + "error", + "-re", + "-i", + path, + "-vf", + video_filter, + "-f", + "rawvideo", + "-pix_fmt", + "rgb24", + "-", + ] + try: + return subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL) + except OSError: + self.log.warning("ffmpeg is not available for video playback") + return None + + def _stop_video(self): + if self.video_process is None: + return + self.video_process.terminate() + try: + self.video_process.wait(timeout=1) + except subprocess.TimeoutExpired: + self.video_process.kill() + self.video_process.wait() + self.video_process = None + + def _render_message(self, message): + self.screen.fill((0, 0, 0)) + font = pygame.font.SysFont("freesans", max(18, int(self.ymax * 0.06))) + rendered = font.render(message, True, (255, 255, 255)) + width, height = rendered.get_size() + self.screen.blit( + rendered, + ((self.xmax - width) / 2, (self.ymax - height) / 2)) + pygame.display.update() diff --git a/piweatherrock/pwr_config_web.py b/piweatherrock/pwr_config_web.py index a09a19b..ea80d0a 100644 --- a/piweatherrock/pwr_config_web.py +++ b/piweatherrock/pwr_config_web.py @@ -44,10 +44,18 @@ 'info_pause': 'Info pause (seconds)', 'info_delay': 'Info delay (seconds)', 'daily_enabled': 'Daily page enabled', - 'daily_pause': 'Daily pause (seconds)', - 'hourly_enabled': 'Hourly page enabled', - 'hourly_pause': 'Hourly pause (seconds)', - 'log_level': 'Log level'}}, + 'daily_pause': 'Daily pause (seconds)', + 'hourly_enabled': 'Hourly page enabled', + 'hourly_pause': 'Hourly pause (seconds)', + 'info_enabled': 'Info page enabled', + 'info_pause_plugin': 'Info page pause (seconds)', + 'media_enabled': 'Local media page enabled', + 'media_pause': 'Local media pause (seconds)', + 'media_path': 'Local media folder', + 'media_shuffle': 'Shuffle local media', + 'media_fit': 'Media fit (contain, cover, stretch)', + 'media_extensions': 'Media extensions', + 'log_level': 'Log level'}}, 'es': {'changes_applied': 'Cambios aplicados', 'config_error': 'Error de configuración', 'error': 'Error', @@ -70,10 +78,18 @@ 'info_pause': 'Pausa de información (segundos)', 'info_delay': 'Retraso de información (segundos)', 'daily_enabled': 'Página diaria activada', - 'daily_pause': 'Pausa diaria (segundos)', - 'hourly_enabled': 'Página horaria activada', - 'hourly_pause': 'Pausa horaria (segundos)', - 'log_level': 'Nivel de log'}}, + 'daily_pause': 'Pausa diaria (segundos)', + 'hourly_enabled': 'Página horaria activada', + 'hourly_pause': 'Pausa horaria (segundos)', + 'info_enabled': 'Página de información activada', + 'info_pause_plugin': 'Pausa de información (segundos)', + 'media_enabled': 'Página de medios locales activada', + 'media_pause': 'Pausa de medios locales (segundos)', + 'media_path': 'Carpeta local de medios', + 'media_shuffle': 'Medios locales en orden aleatorio', + 'media_fit': 'Ajuste de medios (contain, cover, stretch)', + 'media_extensions': 'Extensiones de medios', + 'log_level': 'Nivel de log'}}, 'ca': {'changes_applied': 'Canvis aplicats', 'config_error': 'Error de configuració', 'error': 'Error', diff --git a/piweatherrock/runner.py b/piweatherrock/runner.py index e1640cd..06a6673 100644 --- a/piweatherrock/runner.py +++ b/piweatherrock/runner.py @@ -11,7 +11,7 @@ # that being the case, I decided to have the lint error here instead of # every place they get used. PR's welcome to make pylint happy about this # and pygame.quit() -from pygame.locals import QUIT, VIDEORESIZE, KEYDOWN, K_KP_ENTER, K_q, K_d, K_h, K_i, K_s +from pygame.locals import QUIT, VIDEORESIZE, KEYDOWN, K_KP_ENTER, K_q, K_d, K_h, K_i, K_m, K_s # local imports from piweatherrock.config_manager import ( @@ -26,6 +26,7 @@ from piweatherrock.plugin_weather_daily import PluginWeatherDaily from piweatherrock.plugin_weather_hourly import PluginWeatherHourly from piweatherrock.plugin_info import PluginInfo +from piweatherrock.plugin_media import PluginMedia UI_LOOP_FREQUENCY = 10 @@ -46,7 +47,9 @@ def __init__(self): self.daily = None self.hourly = None self.info = None + self.media = None self.config_watcher = None + self.page_tick_count = 0 def main(self, config_file): self.config = load_config(config_file) @@ -60,6 +63,7 @@ def main(self, config_file): self.daily = PluginWeatherDaily(self.my_weather_rock) self.hourly = PluginWeatherHourly(self.my_weather_rock) self.info = PluginInfo(self.my_weather_rock) + self.media = PluginMedia(self.my_weather_rock) # Default to weather mode. Showing daily weather first. self.switch_to_default_weather_screen() @@ -125,11 +129,11 @@ def process_pygame_events(self): # On 'i' key, set mode to 'info'. elif event.key == K_i: - self.current_screen = 'i' - self.d_count = 0 - self.h_count = 0 - self.non_weather_timeout = 0 - self.periodic_info_activation = 0 + self.switch_to_screen('i') + + # On 'm' key, set mode to local media. + elif event.key == K_m: + self.switch_to_screen('m') # On 's' key, save a screen shot. elif event.key == K_s: @@ -141,36 +145,10 @@ def screen_switcher(self): on a regular basis. """ self.ensure_current_screen_enabled() - - # Automatically switch back to weather display after a couple minutes - if self.current_screen not in ('d', 'h'): - self.periodic_info_activation = 0 - self.non_weather_timeout += 1 - self.d_count = 0 - self.h_count = 0 - - # Default in config.json.sample: pause for 5 minutes on info screen - enabled_weather_screens = self.enabled_weather_screens() - if (enabled_weather_screens - and self.non_weather_timeout > ( - self.config["info_pause"] * UI_LOOP_FREQUENCY)): - self.switch_to_default_weather_screen() - self.my_weather_rock.log.info("Switching to weather mode") - else: - self.non_weather_timeout = 0 - self.periodic_info_activation += 1 - - # Default is to flip between 2 weather screens - # for 15 minutes before showing info screen. - if self.periodic_info_activation > ( - self.config["info_delay"] * UI_LOOP_FREQUENCY): - self.current_screen = 'i' - self.my_weather_rock.log.info("Switching to info mode") - elif (self.periodic_info_activation % ( - ((self.config["plugins"]["daily"]["pause"] * self.d_count) - + (self.config["plugins"]["hourly"]["pause"] * self.h_count)) - * UI_LOOP_FREQUENCY)) == 0: - self.switch_to_next_weather_screen() + self.page_tick_count += 1 + pause = self.screen_pause(self.current_screen) + if pause and self.page_tick_count > pause * UI_LOOP_FREQUENCY: + self.switch_to_next_screen() # Daily Weather Display Mode if self.current_screen == 'd': @@ -220,6 +198,15 @@ def screen_switcher(self): self.my_weather_rock.log.exception( "Error rendering info screen") + # Local media display mode + elif self.current_screen == 'm': + try: + self.media.disp_media(self.my_weather_rock) + except Exception: + self.my_weather_rock.log.exception( + "Error rendering media screen") + self.switch_to_next_screen() + def check_forecast(self): try: self.my_weather_rock.get_forecast() @@ -274,19 +261,30 @@ def enabled_weather_screens(self): enabled.append('h') return enabled + def enabled_screens(self): + enabled = [] + if self.config["plugins"]["daily"].get("enabled", True): + enabled.append('d') + if self.config["plugins"]["hourly"].get("enabled", True): + enabled.append('h') + if self.config["plugins"]["info"].get("enabled", True): + enabled.append('i') + if self.config["plugins"]["media"].get("enabled", False): + enabled.append('m') + return enabled + def ensure_current_screen_enabled(self): - if self.current_screen in ('d', 'h'): - if self.current_screen not in self.enabled_weather_screens(): - self.switch_to_default_weather_screen() + if self.current_screen not in self.enabled_screens(): + self.switch_to_default_weather_screen() def switch_to_default_weather_screen(self): - enabled = self.enabled_weather_screens() + enabled = self.enabled_screens() if not enabled: self.current_screen = 'i' self.d_count = 0 self.h_count = 0 else: - self.switch_to_weather_screen(enabled[0]) + self.switch_to_screen(enabled[0]) def switch_to_weather_screen(self, screen): if screen not in self.enabled_weather_screens(): @@ -294,18 +292,32 @@ def switch_to_weather_screen(self, screen): f"Ignoring disabled weather screen: {screen}") return + self.switch_to_screen(screen) + + def switch_to_screen(self, screen): + if screen not in self.enabled_screens(): + self.my_weather_rock.log.warning( + f"Ignoring disabled screen: {screen}") + return + + previous_plugin = self.plugin_for_screen(self.current_screen) + if previous_plugin and hasattr(previous_plugin, "on_exit"): + previous_plugin.on_exit() + self.current_screen = screen self.d_count = 1 if screen == 'd' else 0 self.h_count = 1 if screen == 'h' else 0 self.non_weather_timeout = 0 self.periodic_info_activation = 0 + self.page_tick_count = 0 + plugin = self.plugin_for_screen(screen) + if plugin and hasattr(plugin, "on_enter"): + plugin.on_enter(self.my_weather_rock) def switch_to_next_weather_screen(self): enabled = self.enabled_weather_screens() if not enabled: - self.current_screen = 'i' - self.d_count = 0 - self.h_count = 0 + self.switch_to_next_screen() return if self.current_screen == 'd' and 'h' in enabled: @@ -317,10 +329,39 @@ def switch_to_next_weather_screen(self): else: self.advance_weather_screen('h', "Staying on HOURLY") + def switch_to_next_screen(self): + enabled = self.enabled_screens() + if not enabled: + return + if self.current_screen not in enabled: + self.switch_to_screen(enabled[0]) + return + current_index = enabled.index(self.current_screen) + self.switch_to_screen(enabled[(current_index + 1) % len(enabled)]) + def advance_weather_screen(self, screen, message): self.my_weather_rock.log.info(message) - self.current_screen = screen + self.switch_to_screen(screen) if screen == 'd': self.d_count += 1 else: self.h_count += 1 + + def screen_pause(self, screen): + plugin_name = { + 'd': "daily", + 'h': "hourly", + 'i': "info", + 'm': "media", + }.get(screen) + if not plugin_name: + return None + return self.config["plugins"][plugin_name].get("pause", 60) + + def plugin_for_screen(self, screen): + return { + 'd': self.daily, + 'h': self.hourly, + 'i': self.info, + 'm': self.media, + }.get(screen) diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index aeaa828..4da4ef0 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -9,6 +9,8 @@ diff_config, load_config, merge_defaults, + normalize_config, + validate_config, write_config_atomic, ) @@ -30,6 +32,15 @@ "plugins": { "daily": {"pause": 60, "enabled": True}, "hourly": {"pause": 60, "enabled": True}, + "info": {"pause": 300, "enabled": True}, + "media": { + "pause": 20, + "enabled": False, + "path": "", + "shuffle": False, + "fit": "contain", + "extensions": "jpg,jpeg,png,gif,bmp,mp4,mov,m4v,avi,webm", + }, }, "log_level": "INFO", } @@ -81,6 +92,28 @@ def test_merge_defaults_adds_nested_missing_keys(self): self.assertTrue(merged["plugins"]["daily"]["enabled"]) self.assertEqual(merged["plugins"]["hourly"]["pause"], 60) + def test_normalize_config_adds_media_and_info_defaults(self): + partial = json.loads(json.dumps(VALID_CONFIG)) + del partial["plugins"]["info"] + del partial["plugins"]["media"] + normalized = normalize_config(partial) + self.assertTrue(normalized["plugins"]["info"]["enabled"]) + self.assertFalse(normalized["plugins"]["media"]["enabled"]) + + def test_validate_config_rejects_no_enabled_pages(self): + config = json.loads(json.dumps(VALID_CONFIG)) + for plugin in config["plugins"].values(): + plugin["enabled"] = False + with self.assertRaises(ConfigError): + validate_config(config) + + def test_validate_config_requires_media_path_when_enabled(self): + config = json.loads(json.dumps(VALID_CONFIG)) + config["plugins"]["media"]["enabled"] = True + config["plugins"]["media"]["path"] = "" + with self.assertRaises(ConfigError): + validate_config(config) + def test_config_watcher_detects_file_replacement(self): with tempfile.TemporaryDirectory() as tmpdir: path = os.path.join(tmpdir, "config.json") From 19b57389113d5fc67ec33f74c86f9af55b624f46 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 22:17:46 +0000 Subject: [PATCH 62/91] Complete media screen localization Agent-Logs-Url: https://github.com/carloshm/PiWeatherRock/sessions/c998c6bd-4110-4f02-89a1-fe764d71e750 Co-authored-by: carloshm <2389573+carloshm@users.noreply.github.com> --- piweatherrock/pwr_config_web.py | 48 ++++++++++++++++++++++++--------- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/piweatherrock/pwr_config_web.py b/piweatherrock/pwr_config_web.py index ea80d0a..7f8901d 100644 --- a/piweatherrock/pwr_config_web.py +++ b/piweatherrock/pwr_config_web.py @@ -112,10 +112,18 @@ 'info_pause': 'Pausa d’informació (segons)', 'info_delay': 'Retard d’informació (segons)', 'daily_enabled': 'Pàgina diària activada', - 'daily_pause': 'Pausa diària (segons)', - 'hourly_enabled': 'Pàgina horària activada', - 'hourly_pause': 'Pausa horària (segons)', - 'log_level': 'Nivell de log'}}, + 'daily_pause': 'Pausa diària (segons)', + 'hourly_enabled': 'Pàgina horària activada', + 'hourly_pause': 'Pausa horària (segons)', + 'info_enabled': 'Pàgina d’informació activada', + 'info_pause_plugin': 'Pausa d’informació (segons)', + 'media_enabled': 'Pàgina de mitjans locals activada', + 'media_pause': 'Pausa de mitjans locals (segons)', + 'media_path': 'Carpeta local de mitjans', + 'media_shuffle': 'Mitjans locals en ordre aleatori', + 'media_fit': 'Ajust de mitjans (contain, cover, stretch)', + 'media_extensions': 'Extensions de mitjans', + 'log_level': 'Nivell de log'}}, 'gl': {'changes_applied': 'Cambios aplicados', 'config_error': 'Erro de configuración', 'error': 'Erro', @@ -138,10 +146,18 @@ 'info_pause': 'Pausa de información (segundos)', 'info_delay': 'Retardo de información (segundos)', 'daily_enabled': 'Páxina diaria activada', - 'daily_pause': 'Pausa diaria (segundos)', - 'hourly_enabled': 'Páxina horaria activada', - 'hourly_pause': 'Pausa horaria (segundos)', - 'log_level': 'Nivel de log'}}, + 'daily_pause': 'Pausa diaria (segundos)', + 'hourly_enabled': 'Páxina horaria activada', + 'hourly_pause': 'Pausa horaria (segundos)', + 'info_enabled': 'Páxina de información activada', + 'info_pause_plugin': 'Pausa de información (segundos)', + 'media_enabled': 'Páxina de medios locais activada', + 'media_pause': 'Pausa de medios locais (segundos)', + 'media_path': 'Cartafol local de medios', + 'media_shuffle': 'Medios locais en orde aleatoria', + 'media_fit': 'Axuste de medios (contain, cover, stretch)', + 'media_extensions': 'Extensións de medios', + 'log_level': 'Nivel de log'}}, 'eu': {'changes_applied': 'Aldaketak aplikatu dira', 'config_error': 'Konfigurazio-errorea', 'error': 'Errorea', @@ -164,10 +180,18 @@ 'info_pause': 'Informazio-pausa (segundoak)', 'info_delay': 'Informazio-atzerapena (segundoak)', 'daily_enabled': 'Eguneko orria gaituta', - 'daily_pause': 'Eguneko pausa (segundoak)', - 'hourly_enabled': 'Orduko orria gaituta', - 'hourly_pause': 'Orduko pausa (segundoak)', - 'log_level': 'Log-maila'}}} + 'daily_pause': 'Eguneko pausa (segundoak)', + 'hourly_enabled': 'Orduko orria gaituta', + 'hourly_pause': 'Orduko pausa (segundoak)', + 'info_enabled': 'Informazio-orria gaituta', + 'info_pause_plugin': 'Informazio-pausa (segundoak)', + 'media_enabled': 'Tokiko multimedia-orria gaituta', + 'media_pause': 'Tokiko multimedia-pausa (segundoak)', + 'media_path': 'Tokiko multimedia-karpeta', + 'media_shuffle': 'Tokiko multimedia ausazko ordenan', + 'media_fit': 'Multimedia doitzea (contain, cover, stretch)', + 'media_extensions': 'Multimedia-luzapenak', + 'log_level': 'Log-maila'}}} class ConfigWebApp: From 202bdf1c2baac9c4bd85a688810352aa853a0ca1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 22:18:07 +0000 Subject: [PATCH 63/91] Avoid repeated video fallback retries Agent-Logs-Url: https://github.com/carloshm/PiWeatherRock/sessions/c998c6bd-4110-4f02-89a1-fe764d71e750 Co-authored-by: carloshm <2389573+carloshm@users.noreply.github.com> --- piweatherrock/plugin_media/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/piweatherrock/plugin_media/__init__.py b/piweatherrock/plugin_media/__init__.py index 32a0696..aba6b68 100644 --- a/piweatherrock/plugin_media/__init__.py +++ b/piweatherrock/plugin_media/__init__.py @@ -32,6 +32,7 @@ def __init__(self, weather_rock): self.current_item = None self.current_surface = None self.video_process = None + self.video_failed_path = None self.last_scan = 0 self.signature = None self.get_rock_values(weather_rock) @@ -72,9 +73,13 @@ def disp_media(self, weather_rock): self.screen.blit(self.current_surface, (0, 0)) pygame.display.update() else: + if self.video_failed_path == path: + self._render_message("Video playback requires ffmpeg") + return if self.video_process is None: self.video_process = self._start_video(path) if self.video_process is None: + self.video_failed_path = path self._render_message("Video playback requires ffmpeg") return frame = self.video_process.stdout.read(self.xmax * self.ymax * 3) @@ -144,6 +149,7 @@ def _config_signature(self): def _next_item(self): self._stop_video() self.current_surface = None + self.video_failed_path = None if not self.items: self.current_index = -1 self.current_item = None From 8b7f003ca1be2718948d97849602477d3b4b22ef Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 22:19:02 +0000 Subject: [PATCH 64/91] Refine media screen helpers Agent-Logs-Url: https://github.com/carloshm/PiWeatherRock/sessions/c998c6bd-4110-4f02-89a1-fe764d71e750 Co-authored-by: carloshm <2389573+carloshm@users.noreply.github.com> --- piweatherrock/plugin_media/__init__.py | 5 ++++- piweatherrock/runner.py | 29 +++++++++++++++----------- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/piweatherrock/plugin_media/__init__.py b/piweatherrock/plugin_media/__init__.py index aba6b68..243e421 100644 --- a/piweatherrock/plugin_media/__init__.py +++ b/piweatherrock/plugin_media/__init__.py @@ -236,7 +236,7 @@ def _start_video(self, path): return None def _stop_video(self): - if self.video_process is None: + if not self._is_video_playing(): return self.video_process.terminate() try: @@ -246,6 +246,9 @@ def _stop_video(self): self.video_process.wait() self.video_process = None + def _is_video_playing(self): + return self.video_process is not None + def _render_message(self, message): self.screen.fill((0, 0, 0)) font = pygame.font.SysFont("freesans", max(18, int(self.ymax * 0.06))) diff --git a/piweatherrock/runner.py b/piweatherrock/runner.py index 06a6673..605b0ae 100644 --- a/piweatherrock/runner.py +++ b/piweatherrock/runner.py @@ -33,6 +33,18 @@ class Runner: + SCREEN_PLUGIN_NAMES = { + 'd': "daily", + 'h': "hourly", + 'i': "info", + 'm': "media", + } + SCREEN_PLUGIN_ATTRS = { + 'd': "daily", + 'h': "hourly", + 'i': "info", + 'm': "media", + } def __init__(self): self.current_screen = None @@ -348,20 +360,13 @@ def advance_weather_screen(self, screen, message): self.h_count += 1 def screen_pause(self, screen): - plugin_name = { - 'd': "daily", - 'h': "hourly", - 'i': "info", - 'm': "media", - }.get(screen) + plugin_name = self.SCREEN_PLUGIN_NAMES.get(screen) if not plugin_name: return None return self.config["plugins"][plugin_name].get("pause", 60) def plugin_for_screen(self, screen): - return { - 'd': self.daily, - 'h': self.hourly, - 'i': self.info, - 'm': self.media, - }.get(screen) + plugin_attr = self.SCREEN_PLUGIN_ATTRS.get(screen) + if not plugin_attr: + return None + return getattr(self, plugin_attr) From 1866064d83f1842f423b25521655df0cbb35a474 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 22:19:51 +0000 Subject: [PATCH 65/91] Address media validation feedback Agent-Logs-Url: https://github.com/carloshm/PiWeatherRock/sessions/c998c6bd-4110-4f02-89a1-fe764d71e750 Co-authored-by: carloshm <2389573+carloshm@users.noreply.github.com> --- piweatherrock/plugin_media/__init__.py | 10 +++++++++- piweatherrock/runner.py | 8 +------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/piweatherrock/plugin_media/__init__.py b/piweatherrock/plugin_media/__init__.py index 243e421..1c1a46e 100644 --- a/piweatherrock/plugin_media/__init__.py +++ b/piweatherrock/plugin_media/__init__.py @@ -84,6 +84,7 @@ def disp_media(self, weather_rock): return frame = self.video_process.stdout.read(self.xmax * self.ymax * 3) if len(frame) != self.xmax * self.ymax * 3: + self.log.info("Finished media video %s", path) self._next_item() return surface = pygame.image.frombuffer(frame, (self.xmax, self.ymax), "RGB") @@ -108,7 +109,14 @@ def _scan_if_needed(self, force=False): video_ext = set(MEDIA_VIDEO_EXTENSIONS) configured_ext = self._configured_extensions() items = [] - for name in sorted(os.listdir(media_path)): + try: + names = sorted(os.listdir(media_path)) + except OSError: + self.log.exception("Could not scan media directory %s", media_path) + self.items = [] + return + + for name in names: full_path = os.path.join(media_path, name) if not os.path.isfile(full_path): continue diff --git a/piweatherrock/runner.py b/piweatherrock/runner.py index 605b0ae..9ac161f 100644 --- a/piweatherrock/runner.py +++ b/piweatherrock/runner.py @@ -39,12 +39,6 @@ class Runner: 'i': "info", 'm': "media", } - SCREEN_PLUGIN_ATTRS = { - 'd': "daily", - 'h': "hourly", - 'i': "info", - 'm': "media", - } def __init__(self): self.current_screen = None @@ -366,7 +360,7 @@ def screen_pause(self, screen): return self.config["plugins"][plugin_name].get("pause", 60) def plugin_for_screen(self, screen): - plugin_attr = self.SCREEN_PLUGIN_ATTRS.get(screen) + plugin_attr = self.SCREEN_PLUGIN_NAMES.get(screen) if not plugin_attr: return None return getattr(self, plugin_attr) From 2652745dae3a5a4ddc109f0527443c6b86b6c467 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 22:20:45 +0000 Subject: [PATCH 66/91] Clarify media screen helper names Agent-Logs-Url: https://github.com/carloshm/PiWeatherRock/sessions/c998c6bd-4110-4f02-89a1-fe764d71e750 Co-authored-by: carloshm <2389573+carloshm@users.noreply.github.com> --- piweatherrock/config_manager.py | 5 +++-- piweatherrock/plugin_media/__init__.py | 4 +++- piweatherrock/runner.py | 10 +++++----- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/piweatherrock/config_manager.py b/piweatherrock/config_manager.py index 1f3b105..b9e213e 100644 --- a/piweatherrock/config_manager.py +++ b/piweatherrock/config_manager.py @@ -368,8 +368,9 @@ def _validate_media_plugin(plugin_config, errors): def _split_extensions(value): - return [part.strip().lower().lstrip(".") - for part in value.split(",") if part.strip()] + return [extension.lower().lstrip(".") + for extension in (part.strip() for part in value.split(",")) + if extension] def _collect_diff(path, old_value, new_value, changed): diff --git a/piweatherrock/plugin_media/__init__.py b/piweatherrock/plugin_media/__init__.py index 1c1a46e..21641c7 100644 --- a/piweatherrock/plugin_media/__init__.py +++ b/piweatherrock/plugin_media/__init__.py @@ -19,6 +19,7 @@ class PluginMedia: """Displays images and short videos from a configured local folder.""" SCAN_INTERVAL = 30 + FONT_SIZE_RATIO = 0.06 def __init__(self, weather_rock): self.config = None @@ -259,7 +260,8 @@ def _is_video_playing(self): def _render_message(self, message): self.screen.fill((0, 0, 0)) - font = pygame.font.SysFont("freesans", max(18, int(self.ymax * 0.06))) + font = pygame.font.SysFont( + "freesans", max(18, int(self.ymax * self.FONT_SIZE_RATIO))) rendered = font.render(message, True, (255, 255, 255)) width, height = rendered.get_size() self.screen.blit( diff --git a/piweatherrock/runner.py b/piweatherrock/runner.py index 9ac161f..1be051c 100644 --- a/piweatherrock/runner.py +++ b/piweatherrock/runner.py @@ -33,7 +33,7 @@ class Runner: - SCREEN_PLUGIN_NAMES = { + SCREEN_TO_PLUGIN_NAME = { 'd': "daily", 'h': "hourly", 'i': "info", @@ -354,13 +354,13 @@ def advance_weather_screen(self, screen, message): self.h_count += 1 def screen_pause(self, screen): - plugin_name = self.SCREEN_PLUGIN_NAMES.get(screen) + plugin_name = self.SCREEN_TO_PLUGIN_NAME.get(screen) if not plugin_name: return None return self.config["plugins"][plugin_name].get("pause", 60) def plugin_for_screen(self, screen): - plugin_attr = self.SCREEN_PLUGIN_NAMES.get(screen) - if not plugin_attr: + plugin_name = self.SCREEN_TO_PLUGIN_NAME.get(screen) + if not plugin_name: return None - return getattr(self, plugin_attr) + return getattr(self, plugin_name) From d22db9e66b4cfc6d0434fe3c2d0a06834c7c6716 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 22:21:25 +0000 Subject: [PATCH 67/91] Harden media fit fallback Agent-Logs-Url: https://github.com/carloshm/PiWeatherRock/sessions/c998c6bd-4110-4f02-89a1-fe764d71e750 Co-authored-by: carloshm <2389573+carloshm@users.noreply.github.com> --- piweatherrock/plugin_media/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/piweatherrock/plugin_media/__init__.py b/piweatherrock/plugin_media/__init__.py index 21641c7..5042429 100644 --- a/piweatherrock/plugin_media/__init__.py +++ b/piweatherrock/plugin_media/__init__.py @@ -176,6 +176,9 @@ def _load_image(self, path): def _fit_surface(self, surface): fit = self.media_config.get("fit", "contain") + if fit not in ("contain", "cover", "stretch"): + self.log.warning("Unsupported media fit mode %s; using contain", fit) + fit = "contain" width, height = surface.get_size() if fit == "stretch": return pygame.transform.smoothscale(surface, (self.xmax, self.ymax)) @@ -206,6 +209,9 @@ def _fit_surface(self, surface): def _start_video(self, path): fit = self.media_config.get("fit", "contain") + if fit not in ("contain", "cover", "stretch"): + self.log.warning("Unsupported media fit mode %s; using contain", fit) + fit = "contain" if fit == "cover": video_filter = ( "scale={0}:{1}:force_original_aspect_ratio=increase," From f8004be345b95a490f268ebc14d2981f31c5933e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 22:22:15 +0000 Subject: [PATCH 68/91] Make video frame reads nonblocking Agent-Logs-Url: https://github.com/carloshm/PiWeatherRock/sessions/c998c6bd-4110-4f02-89a1-fe764d71e750 Co-authored-by: carloshm <2389573+carloshm@users.noreply.github.com> --- piweatherrock/config_manager.py | 9 ++++-- piweatherrock/plugin_media/__init__.py | 40 ++++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/piweatherrock/config_manager.py b/piweatherrock/config_manager.py index b9e213e..89846c7 100644 --- a/piweatherrock/config_manager.py +++ b/piweatherrock/config_manager.py @@ -368,9 +368,12 @@ def _validate_media_plugin(plugin_config, errors): def _split_extensions(value): - return [extension.lower().lstrip(".") - for extension in (part.strip() for part in value.split(",")) - if extension] + extensions = [] + for part in value.split(","): + extension = part.strip().lower().lstrip(".") + if extension: + extensions.append(extension) + return extensions def _collect_diff(path, old_value, new_value, changed): diff --git a/piweatherrock/plugin_media/__init__.py b/piweatherrock/plugin_media/__init__.py index 5042429..5304a04 100644 --- a/piweatherrock/plugin_media/__init__.py +++ b/piweatherrock/plugin_media/__init__.py @@ -3,6 +3,7 @@ import os import random +import select import subprocess import time import math @@ -34,6 +35,7 @@ def __init__(self, weather_rock): self.current_surface = None self.video_process = None self.video_failed_path = None + self.video_buffer = b"" self.last_scan = 0 self.signature = None self.get_rock_values(weather_rock) @@ -83,8 +85,10 @@ def disp_media(self, weather_rock): self.video_failed_path = path self._render_message("Video playback requires ffmpeg") return - frame = self.video_process.stdout.read(self.xmax * self.ymax * 3) - if len(frame) != self.xmax * self.ymax * 3: + frame = self._read_video_frame(path) + if frame is None: + return + if not frame: self.log.info("Finished media video %s", path) self._next_item() return @@ -159,6 +163,7 @@ def _next_item(self): self._stop_video() self.current_surface = None self.video_failed_path = None + self.video_buffer = b"" if not self.items: self.current_index = -1 self.current_item = None @@ -183,9 +188,10 @@ def _fit_surface(self, surface): if fit == "stretch": return pygame.transform.smoothscale(surface, (self.xmax, self.ymax)) - scale = max(self.xmax / width, self.ymax / height) if fit == "contain": scale = min(self.xmax / width, self.ymax / height) + else: + scale = max(self.xmax / width, self.ymax / height) if fit == "cover": new_size = ( max(self.xmax, int(math.ceil(width * scale))), @@ -264,6 +270,34 @@ def _stop_video(self): def _is_video_playing(self): return self.video_process is not None + def _read_video_frame(self, path): + frame_size = self.xmax * self.ymax * 3 + process = self.video_process + if process.poll() is not None: + if not self.video_buffer: + return b"" + + ready, _, _ = select.select([process.stdout], [], [], 0.2) + if not ready: + if process.poll() is not None: + return b"" + return None + + try: + chunk = os.read(process.stdout.fileno(), + frame_size - len(self.video_buffer)) + except OSError: + self.log.exception("Could not read media video %s", path) + return b"" + if not chunk: + return b"" + self.video_buffer += chunk + if len(self.video_buffer) < frame_size: + return None + frame = self.video_buffer[:frame_size] + self.video_buffer = self.video_buffer[frame_size:] + return frame + def _render_message(self, message): self.screen.fill((0, 0, 0)) font = pygame.font.SysFont( From 2057c313cc14241f44506ae241c45922cac1319d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 22:22:59 +0000 Subject: [PATCH 69/91] Document media configuration constraints Agent-Logs-Url: https://github.com/carloshm/PiWeatherRock/sessions/c998c6bd-4110-4f02-89a1-fe764d71e750 Co-authored-by: carloshm <2389573+carloshm@users.noreply.github.com> --- README.md | 1 + docs/README.md | 2 +- piweatherrock/plugin_media/__init__.py | 4 +++- piweatherrock/runner.py | 2 ++ 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ce279ef..0bff536 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Weather settings are configured in `piweatherrock/piweatherrock-config.json`: - `plugins.media`: Local media screen settings for images and short videos loaded from a folder. Configure `enabled`, `pause`, `path`, `shuffle`, `fit` (`contain`, `cover`, or `stretch`), and allowed `extensions`. + The folder in `path` must already exist before enabling this screen. PiWeatherRock automatically checks the config file while `pwr-ui` is running. Valid changes are applied without restarting the display. If the JSON is invalid, diff --git a/docs/README.md b/docs/README.md index 8a92e28..688d52d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -60,7 +60,7 @@ La pantalla de información reduce el contenido visual para ayudar a evitar quem ## Pantalla de medios locales -La pantalla de medios locales funciona como marco digital. Lee imágenes y vídeos cortos de una carpeta local configurada en `plugins.media.path`, los escala a la pantalla y permite elegir el modo de ajuste: +La pantalla de medios locales funciona como marco digital. Lee imágenes y vídeos cortos de una carpeta local configurada en `plugins.media.path`, que debe existir antes de activar `plugins.media.enabled`, los escala a la pantalla y permite elegir el modo de ajuste: - `contain`: muestra el archivo completo con bandas si hace falta. - `cover`: llena toda la pantalla recortando lo necesario. diff --git a/piweatherrock/plugin_media/__init__.py b/piweatherrock/plugin_media/__init__.py index 5304a04..7cedf23 100644 --- a/piweatherrock/plugin_media/__init__.py +++ b/piweatherrock/plugin_media/__init__.py @@ -21,6 +21,7 @@ class PluginMedia: SCAN_INTERVAL = 30 FONT_SIZE_RATIO = 0.06 + VIDEO_READ_TIMEOUT = 0.2 def __init__(self, weather_rock): self.config = None @@ -277,7 +278,8 @@ def _read_video_frame(self, path): if not self.video_buffer: return b"" - ready, _, _ = select.select([process.stdout], [], [], 0.2) + ready, _, _ = select.select( + [process.stdout], [], [], self.VIDEO_READ_TIMEOUT) if not ready: if process.poll() is not None: return b"" diff --git a/piweatherrock/runner.py b/piweatherrock/runner.py index 1be051c..1bddc16 100644 --- a/piweatherrock/runner.py +++ b/piweatherrock/runner.py @@ -33,6 +33,8 @@ class Runner: + # Keyboard shortcuts and rotation state use short screen IDs; config stores + # the matching plugin names under the "plugins" object. SCREEN_TO_PLUGIN_NAME = { 'd': "daily", 'h': "hourly", From bc18627e0e35fa2f03606b2b8dee5ce502f6592b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 12:36:53 +0000 Subject: [PATCH 70/91] Force LF line endings for shell scripts Agent-Logs-Url: https://github.com/carloshm/PiWeatherRock/sessions/ca5e7437-e0e0-43e3-8150-00ff71b0d3f2 Co-authored-by: carloshm <2389573+carloshm@users.noreply.github.com> --- .gitattributes | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfdb8b7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf From fed260dccbae1dcaac7148245ef66a0c0a418a36 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 13:21:47 +0000 Subject: [PATCH 71/91] Improve web config UI structure Agent-Logs-Url: https://github.com/carloshm/PiWeatherRock/sessions/e76fc947-1165-497b-8ace-61e01b397967 Co-authored-by: carloshm <2389573+carloshm@users.noreply.github.com> --- piweatherrock/config_manager.py | 10 + piweatherrock/pwr_config_web.py | 712 ++++++++++++++++++++++++-------- tests/test_config_manager.py | 6 + tests/test_config_web.py | 70 ++++ 4 files changed, 631 insertions(+), 167 deletions(-) create mode 100644 tests/test_config_web.py diff --git a/piweatherrock/config_manager.py b/piweatherrock/config_manager.py index 89846c7..2e2c895 100644 --- a/piweatherrock/config_manager.py +++ b/piweatherrock/config_manager.py @@ -8,6 +8,8 @@ import tempfile import time +from pytz import common_timezones + class ConfigError(Exception): """Raised when a configuration file cannot be loaded or validated.""" @@ -64,6 +66,7 @@ class ConfigError(Exception): MEDIA_FIT_MODES = ("contain", "cover", "stretch") SUPPORTED_LANGUAGES = ("en", "es", "ca", "gl", "eu") +SUPPORTED_TIMEZONES = tuple(common_timezones) WEATHER_RELOAD_PATHS = { ("ds_api_key",), @@ -176,6 +179,7 @@ def validate_config(config): _validate_positive_int(config, "info_delay", errors) _validate_language(config, "lang", errors) _validate_language(config, "ui_lang", errors) + _validate_timezone(config, errors) if isinstance(plugins, dict): enabled_count = 0 @@ -343,6 +347,12 @@ def _validate_language(config, key, errors): key, ", ".join(SUPPORTED_LANGUAGES))) +def _validate_timezone(config, errors): + value = config.get("timezone") + if isinstance(value, str) and value not in SUPPORTED_TIMEZONES: + errors.append("Field 'timezone' must be a valid timezone") + + def _validate_media_plugin(plugin_config, errors): if not isinstance(plugin_config.get("path"), str): errors.append("Field plugins.media.path has invalid type") diff --git a/piweatherrock/pwr_config_web.py b/piweatherrock/pwr_config_web.py index 7f8901d..2e5405d 100644 --- a/piweatherrock/pwr_config_web.py +++ b/piweatherrock/pwr_config_web.py @@ -12,186 +12,322 @@ from piweatherrock.config_manager import ( CONFIG_FORM_FIELDS, ConfigError, + MEDIA_FIT_MODES, field_name, get_config_value, load_config, SUPPORTED_LANGUAGES, + SUPPORTED_TIMEZONES, set_config_value, validate_config, write_config_atomic, ) -TEXT = {'en': {'changes_applied': 'Changes applied', +TEXT = { + 'en': { + 'changes_applied': 'Changes applied', 'config_error': 'Configuration error', 'error': 'Error', 'legend': 'PiWeatherRock configuration', + 'open_map': 'Open larger map', + 'map_hint': 'Use the map as a visual reference for latitude and longitude.', + 'map_title': 'Location map preview', 'save': 'Save changes', 'status_ok': 'OK: valid configuration. The UI will apply changes automatically.', + 'subtitle': 'Adjust weather, display, rotation, and local media options from one guided form.', 'title': 'PiWeatherRock Config', 'validate': 'Validate configuration', - 'labels': {'lat': 'Latitude', - 'lon': 'Longitude', - 'timezone': 'Timezone', - 'ds_api_key': 'Open-Meteo identifier', - 'units': 'Units', - 'lang': 'Weather language', - 'ui_lang': 'UI language', - 'update_freq': 'Forecast frequency (seconds)', - 'fullscreen': 'Fullscreen', - '12hour_disp': '12-hour format', - 'icon_offset': 'Icon offset', - 'info_pause': 'Info pause (seconds)', - 'info_delay': 'Info delay (seconds)', - 'daily_enabled': 'Daily page enabled', - 'daily_pause': 'Daily pause (seconds)', - 'hourly_enabled': 'Hourly page enabled', - 'hourly_pause': 'Hourly pause (seconds)', - 'info_enabled': 'Info page enabled', - 'info_pause_plugin': 'Info page pause (seconds)', - 'media_enabled': 'Local media page enabled', - 'media_pause': 'Local media pause (seconds)', - 'media_path': 'Local media folder', - 'media_shuffle': 'Shuffle local media', - 'media_fit': 'Media fit (contain, cover, stretch)', - 'media_extensions': 'Media extensions', - 'log_level': 'Log level'}}, - 'es': {'changes_applied': 'Cambios aplicados', + 'labels': { + 'lat': 'Latitude', + 'lon': 'Longitude', + 'timezone': 'Timezone', + 'ds_api_key': 'Open-Meteo identifier', + 'units': 'Units', + 'lang': 'Weather language', + 'ui_lang': 'UI language', + 'update_freq': 'Forecast refresh interval (seconds)', + 'fullscreen': 'Fullscreen', + '12hour_disp': '12-hour format', + 'icon_offset': 'Icon offset', + 'info_pause': 'Pause between full page cycles (seconds)', + 'info_delay': 'Delay before showing info page (seconds)', + 'daily_enabled': 'Daily page enabled', + 'daily_pause': 'Daily page display pause (seconds)', + 'hourly_enabled': 'Hourly page enabled', + 'hourly_pause': 'Hourly page display pause (seconds)', + 'info_enabled': 'Info page enabled', + 'info_pause_plugin': 'Info page display pause (seconds)', + 'media_enabled': 'Local media page enabled', + 'media_pause': 'Pause between local media items (seconds)', + 'media_path': 'Local media folder', + 'media_shuffle': 'Shuffle local media', + 'media_fit': 'Media fit', + 'media_extensions': 'Media extensions', + 'log_level': 'Log level'}, + 'sections': { + 'location': 'Location and timezone', + 'weather': 'Weather and language', + 'display': 'Display', + 'rotation': 'Page rotation pauses', + 'media': 'Local media', + 'diagnostics': 'Diagnostics'}, + 'help': { + 'location': 'Coordinates and timezone used for forecast, sunrise, and sunset calculations.', + 'weather': 'Open-Meteo request identifier, units, and languages shown in the weather and UI texts.', + 'display': 'Screen presentation options for the Raspberry Pi display.', + 'rotation': 'The global pause controls complete page cycles; each page pause controls how long that section stays visible.', + 'media': 'Local folder, ordering, fit mode, and pause between individual media files.', + 'diagnostics': 'Logging verbosity for troubleshooting.'}}, + 'es': { + 'changes_applied': 'Cambios aplicados', 'config_error': 'Error de configuración', 'error': 'Error', 'legend': 'Configuración PiWeatherRock', + 'open_map': 'Abrir mapa grande', + 'map_hint': 'Usa el mapa como referencia visual para latitud y longitud.', + 'map_title': 'Vista previa del mapa de ubicación', 'save': 'Guardar cambios', 'status_ok': 'OK: configuración válida. La UI aplicará los cambios automáticamente.', + 'subtitle': 'Ajusta meteorología, pantalla, rotación y medios locales desde un formulario guiado.', 'title': 'Configuración de PiWeatherRock', 'validate': 'Validar configuración', - 'labels': {'lat': 'Latitud', - 'lon': 'Longitud', - 'timezone': 'Zona horaria', - 'ds_api_key': 'Identificador Open-Meteo', - 'units': 'Unidades', - 'lang': 'Idioma meteorológico', - 'ui_lang': 'Idioma de interfaz', - 'update_freq': 'Frecuencia del pronóstico (segundos)', - 'fullscreen': 'Pantalla completa', - '12hour_disp': 'Formato de 12 horas', - 'icon_offset': 'Desplazamiento de iconos', - 'info_pause': 'Pausa de información (segundos)', - 'info_delay': 'Retraso de información (segundos)', - 'daily_enabled': 'Página diaria activada', - 'daily_pause': 'Pausa diaria (segundos)', - 'hourly_enabled': 'Página horaria activada', - 'hourly_pause': 'Pausa horaria (segundos)', - 'info_enabled': 'Página de información activada', - 'info_pause_plugin': 'Pausa de información (segundos)', - 'media_enabled': 'Página de medios locales activada', - 'media_pause': 'Pausa de medios locales (segundos)', - 'media_path': 'Carpeta local de medios', - 'media_shuffle': 'Medios locales en orden aleatorio', - 'media_fit': 'Ajuste de medios (contain, cover, stretch)', - 'media_extensions': 'Extensiones de medios', - 'log_level': 'Nivel de log'}}, - 'ca': {'changes_applied': 'Canvis aplicats', + 'labels': { + 'lat': 'Latitud', + 'lon': 'Longitud', + 'timezone': 'Zona horaria', + 'ds_api_key': 'Identificador Open-Meteo', + 'units': 'Unidades', + 'lang': 'Idioma meteorológico', + 'ui_lang': 'Idioma de interfaz', + 'update_freq': 'Intervalo de actualización del pronóstico (segundos)', + 'fullscreen': 'Pantalla completa', + '12hour_disp': 'Formato de 12 horas', + 'icon_offset': 'Desplazamiento de iconos', + 'info_pause': 'Pausa entre ciclos completos de páginas (segundos)', + 'info_delay': 'Retraso antes de mostrar información (segundos)', + 'daily_enabled': 'Página diaria activada', + 'daily_pause': 'Pausa de visualización de página diaria (segundos)', + 'hourly_enabled': 'Página horaria activada', + 'hourly_pause': 'Pausa de visualización de página horaria (segundos)', + 'info_enabled': 'Página de información activada', + 'info_pause_plugin': 'Pausa de visualización de página de información (segundos)', + 'media_enabled': 'Página de medios locales activada', + 'media_pause': 'Pausa entre archivos de medios locales (segundos)', + 'media_path': 'Carpeta local de medios', + 'media_shuffle': 'Medios locales en orden aleatorio', + 'media_fit': 'Ajuste de medios', + 'media_extensions': 'Extensiones de medios', + 'log_level': 'Nivel de log'}, + 'sections': { + 'location': 'Ubicación y zona horaria', + 'weather': 'Meteorología e idioma', + 'display': 'Pantalla', + 'rotation': 'Pausas de rotación de páginas', + 'media': 'Medios locales', + 'diagnostics': 'Diagnóstico'}, + 'help': { + 'location': 'Coordenadas y zona horaria usadas para el pronóstico, amanecer y atardecer.', + 'weather': 'Identificador Open-Meteo, unidades e idiomas mostrados en la meteorología y la interfaz.', + 'display': 'Opciones de presentación para la pantalla de la Raspberry Pi.', + 'rotation': 'La pausa global controla ciclos completos de páginas; cada pausa de sección define cuánto tiempo queda visible.', + 'media': 'Carpeta local, orden, modo de ajuste y pausa entre archivos de medios individuales.', + 'diagnostics': 'Nivel de detalle de los registros para solucionar problemas.'}}, + 'ca': { + 'changes_applied': 'Canvis aplicats', 'config_error': 'Error de configuració', 'error': 'Error', 'legend': 'Configuració de PiWeatherRock', + 'open_map': 'Obre el mapa gran', + 'map_hint': 'Fes servir el mapa com a referència visual per a latitud i longitud.', + 'map_title': 'Vista prèvia del mapa d’ubicació', 'save': 'Desa els canvis', 'status_ok': 'OK: configuració vàlida. La UI aplicarà els canvis automàticament.', + 'subtitle': 'Ajusta meteorologia, pantalla, rotació i mitjans locals des d’un formulari guiat.', 'title': 'Configuració de PiWeatherRock', 'validate': 'Valida la configuració', - 'labels': {'lat': 'Latitud', - 'lon': 'Longitud', - 'timezone': 'Zona horària', - 'ds_api_key': 'Identificador Open-Meteo', - 'units': 'Unitats', - 'lang': 'Idioma meteorològic', - 'ui_lang': 'Idioma de la interfície', - 'update_freq': 'Freqüència de la previsió (segons)', - 'fullscreen': 'Pantalla completa', - '12hour_disp': 'Format de 12 hores', - 'icon_offset': 'Desplaçament de les icones', - 'info_pause': 'Pausa d’informació (segons)', - 'info_delay': 'Retard d’informació (segons)', - 'daily_enabled': 'Pàgina diària activada', - 'daily_pause': 'Pausa diària (segons)', - 'hourly_enabled': 'Pàgina horària activada', - 'hourly_pause': 'Pausa horària (segons)', - 'info_enabled': 'Pàgina d’informació activada', - 'info_pause_plugin': 'Pausa d’informació (segons)', - 'media_enabled': 'Pàgina de mitjans locals activada', - 'media_pause': 'Pausa de mitjans locals (segons)', - 'media_path': 'Carpeta local de mitjans', - 'media_shuffle': 'Mitjans locals en ordre aleatori', - 'media_fit': 'Ajust de mitjans (contain, cover, stretch)', - 'media_extensions': 'Extensions de mitjans', - 'log_level': 'Nivell de log'}}, - 'gl': {'changes_applied': 'Cambios aplicados', + 'labels': { + 'lat': 'Latitud', + 'lon': 'Longitud', + 'timezone': 'Zona horària', + 'ds_api_key': 'Identificador Open-Meteo', + 'units': 'Unitats', + 'lang': 'Idioma meteorològic', + 'ui_lang': 'Idioma de la interfície', + 'update_freq': 'Interval d’actualització de la previsió (segons)', + 'fullscreen': 'Pantalla completa', + '12hour_disp': 'Format de 12 hores', + 'icon_offset': 'Desplaçament de les icones', + 'info_pause': 'Pausa entre cicles complets de pàgines (segons)', + 'info_delay': 'Retard abans de mostrar informació (segons)', + 'daily_enabled': 'Pàgina diària activada', + 'daily_pause': 'Pausa de visualització de pàgina diària (segons)', + 'hourly_enabled': 'Pàgina horària activada', + 'hourly_pause': 'Pausa de visualització de pàgina horària (segons)', + 'info_enabled': 'Pàgina d’informació activada', + 'info_pause_plugin': 'Pausa de visualització de pàgina d’informació (segons)', + 'media_enabled': 'Pàgina de mitjans locals activada', + 'media_pause': 'Pausa entre fitxers de mitjans locals (segons)', + 'media_path': 'Carpeta local de mitjans', + 'media_shuffle': 'Mitjans locals en ordre aleatori', + 'media_fit': 'Ajust de mitjans', + 'media_extensions': 'Extensions de mitjans', + 'log_level': 'Nivell de log'}, + 'sections': { + 'location': 'Ubicació i zona horària', + 'weather': 'Meteorologia i idioma', + 'display': 'Pantalla', + 'rotation': 'Pauses de rotació de pàgines', + 'media': 'Mitjans locals', + 'diagnostics': 'Diagnòstic'}, + 'help': { + 'location': 'Coordenades i zona horària usades per a la previsió, sortida i posta de sol.', + 'weather': 'Identificador Open-Meteo, unitats i idiomes mostrats a la meteorologia i la interfície.', + 'display': 'Opcions de presentació per a la pantalla de la Raspberry Pi.', + 'rotation': 'La pausa global controla cicles complets de pàgines; cada pausa de secció defineix quant temps queda visible.', + 'media': 'Carpeta local, ordre, mode d’ajust i pausa entre fitxers de mitjans individuals.', + 'diagnostics': 'Nivell de detall dels registres per resoldre problemes.'}}, + 'gl': { + 'changes_applied': 'Cambios aplicados', 'config_error': 'Erro de configuración', 'error': 'Erro', 'legend': 'Configuración de PiWeatherRock', + 'open_map': 'Abrir mapa grande', + 'map_hint': 'Usa o mapa como referencia visual para latitude e lonxitude.', + 'map_title': 'Vista previa do mapa de localización', 'save': 'Gardar cambios', 'status_ok': 'OK: configuración válida. A UI aplicará os cambios automaticamente.', + 'subtitle': 'Axusta meteoroloxía, pantalla, rotación e medios locais desde un formulario guiado.', 'title': 'Configuración de PiWeatherRock', 'validate': 'Validar configuración', - 'labels': {'lat': 'Latitude', - 'lon': 'Lonxitude', - 'timezone': 'Zona horaria', - 'ds_api_key': 'Identificador Open-Meteo', - 'units': 'Unidades', - 'lang': 'Idioma meteorolóxico', - 'ui_lang': 'Idioma da interface', - 'update_freq': 'Frecuencia da predición (segundos)', - 'fullscreen': 'Pantalla completa', - '12hour_disp': 'Formato de 12 horas', - 'icon_offset': 'Desprazamento das iconas', - 'info_pause': 'Pausa de información (segundos)', - 'info_delay': 'Retardo de información (segundos)', - 'daily_enabled': 'Páxina diaria activada', - 'daily_pause': 'Pausa diaria (segundos)', - 'hourly_enabled': 'Páxina horaria activada', - 'hourly_pause': 'Pausa horaria (segundos)', - 'info_enabled': 'Páxina de información activada', - 'info_pause_plugin': 'Pausa de información (segundos)', - 'media_enabled': 'Páxina de medios locais activada', - 'media_pause': 'Pausa de medios locais (segundos)', - 'media_path': 'Cartafol local de medios', - 'media_shuffle': 'Medios locais en orde aleatoria', - 'media_fit': 'Axuste de medios (contain, cover, stretch)', - 'media_extensions': 'Extensións de medios', - 'log_level': 'Nivel de log'}}, - 'eu': {'changes_applied': 'Aldaketak aplikatu dira', + 'labels': { + 'lat': 'Latitude', + 'lon': 'Lonxitude', + 'timezone': 'Zona horaria', + 'ds_api_key': 'Identificador Open-Meteo', + 'units': 'Unidades', + 'lang': 'Idioma meteorolóxico', + 'ui_lang': 'Idioma da interface', + 'update_freq': 'Intervalo de actualización da predición (segundos)', + 'fullscreen': 'Pantalla completa', + '12hour_disp': 'Formato de 12 horas', + 'icon_offset': 'Desprazamento das iconas', + 'info_pause': 'Pausa entre ciclos completos de páxinas (segundos)', + 'info_delay': 'Retardo antes de mostrar información (segundos)', + 'daily_enabled': 'Páxina diaria activada', + 'daily_pause': 'Pausa de visualización da páxina diaria (segundos)', + 'hourly_enabled': 'Páxina horaria activada', + 'hourly_pause': 'Pausa de visualización da páxina horaria (segundos)', + 'info_enabled': 'Páxina de información activada', + 'info_pause_plugin': 'Pausa de visualización da páxina de información (segundos)', + 'media_enabled': 'Páxina de medios locais activada', + 'media_pause': 'Pausa entre ficheiros de medios locais (segundos)', + 'media_path': 'Cartafol local de medios', + 'media_shuffle': 'Medios locais en orde aleatoria', + 'media_fit': 'Axuste de medios', + 'media_extensions': 'Extensións de medios', + 'log_level': 'Nivel de log'}, + 'sections': { + 'location': 'Localización e zona horaria', + 'weather': 'Meteoroloxía e idioma', + 'display': 'Pantalla', + 'rotation': 'Pausas de rotación de páxinas', + 'media': 'Medios locais', + 'diagnostics': 'Diagnóstico'}, + 'help': { + 'location': 'Coordenadas e zona horaria usadas para a predición, amencer e solpor.', + 'weather': 'Identificador Open-Meteo, unidades e idiomas mostrados na meteoroloxía e na interface.', + 'display': 'Opcións de presentación para a pantalla da Raspberry Pi.', + 'rotation': 'A pausa global controla ciclos completos de páxinas; cada pausa de sección define canto tempo queda visible.', + 'media': 'Cartafol local, orde, modo de axuste e pausa entre ficheiros de medios individuais.', + 'diagnostics': 'Nivel de detalle dos rexistros para resolver problemas.'}}, + 'eu': { + 'changes_applied': 'Aldaketak aplikatu dira', 'config_error': 'Konfigurazio-errorea', 'error': 'Errorea', 'legend': 'PiWeatherRock konfigurazioa', + 'open_map': 'Ireki mapa handia', + 'map_hint': 'Erabili mapa latitudearen eta longitudearen erreferentzia bisual gisa.', + 'map_title': 'Kokapen maparen aurrebista', 'save': 'Gorde aldaketak', 'status_ok': 'OK: konfigurazioa baliozkoa da. UIak aldaketak automatikoki aplikatuko ditu.', + 'subtitle': 'Eguraldia, pantaila, biraketa eta tokiko multimedia formulario gidatu batetik doitu.', 'title': 'PiWeatherRock konfigurazioa', 'validate': 'Balidatu konfigurazioa', - 'labels': {'lat': 'Latitudea', - 'lon': 'Longitudea', - 'timezone': 'Ordu-zona', - 'ds_api_key': 'Open-Meteo identifikatzailea', - 'units': 'Unitateak', - 'lang': 'Eguraldiaren hizkuntza', - 'ui_lang': 'Interfazearen hizkuntza', - 'update_freq': 'Iragarpenaren maiztasuna (segundoak)', - 'fullscreen': 'Pantaila osoa', - '12hour_disp': '12 orduko formatua', - 'icon_offset': 'Ikonoen desplazamendua', - 'info_pause': 'Informazio-pausa (segundoak)', - 'info_delay': 'Informazio-atzerapena (segundoak)', - 'daily_enabled': 'Eguneko orria gaituta', - 'daily_pause': 'Eguneko pausa (segundoak)', - 'hourly_enabled': 'Orduko orria gaituta', - 'hourly_pause': 'Orduko pausa (segundoak)', - 'info_enabled': 'Informazio-orria gaituta', - 'info_pause_plugin': 'Informazio-pausa (segundoak)', - 'media_enabled': 'Tokiko multimedia-orria gaituta', - 'media_pause': 'Tokiko multimedia-pausa (segundoak)', - 'media_path': 'Tokiko multimedia-karpeta', - 'media_shuffle': 'Tokiko multimedia ausazko ordenan', - 'media_fit': 'Multimedia doitzea (contain, cover, stretch)', - 'media_extensions': 'Multimedia-luzapenak', - 'log_level': 'Log-maila'}}} + 'labels': { + 'lat': 'Latitudea', + 'lon': 'Longitudea', + 'timezone': 'Ordu-zona', + 'ds_api_key': 'Open-Meteo identifikatzailea', + 'units': 'Unitateak', + 'lang': 'Eguraldiaren hizkuntza', + 'ui_lang': 'Interfazearen hizkuntza', + 'update_freq': 'Iragarpenaren eguneratze-tartea (segundoak)', + 'fullscreen': 'Pantaila osoa', + '12hour_disp': '12 orduko formatua', + 'icon_offset': 'Ikonoen desplazamendua', + 'info_pause': 'Orrialde-ziklo osoen arteko pausa (segundoak)', + 'info_delay': 'Informazioa erakutsi aurreko atzerapena (segundoak)', + 'daily_enabled': 'Eguneko orria gaituta', + 'daily_pause': 'Eguneko orriaren bistaratze-pausa (segundoak)', + 'hourly_enabled': 'Orduko orria gaituta', + 'hourly_pause': 'Orduko orriaren bistaratze-pausa (segundoak)', + 'info_enabled': 'Informazio-orria gaituta', + 'info_pause_plugin': 'Informazio-orriaren bistaratze-pausa (segundoak)', + 'media_enabled': 'Tokiko multimedia-orria gaituta', + 'media_pause': 'Tokiko multimedia-fitxategien arteko pausa (segundoak)', + 'media_path': 'Tokiko multimedia-karpeta', + 'media_shuffle': 'Tokiko multimedia ausazko ordenan', + 'media_fit': 'Multimedia doitzea', + 'media_extensions': 'Multimedia-luzapenak', + 'log_level': 'Log-maila'}, + 'sections': { + 'location': 'Kokapena eta ordu-zona', + 'weather': 'Eguraldia eta hizkuntza', + 'display': 'Pantaila', + 'rotation': 'Orrialde-biraketaren pausak', + 'media': 'Tokiko multimedia', + 'diagnostics': 'Diagnostikoa'}, + 'help': { + 'location': 'Iragarpenerako, egunsentirako eta ilunabarrerako erabiltzen diren koordenatuak eta ordu-zona.', + 'weather': 'Open-Meteo identifikatzailea, unitateak eta eguraldian nahiz interfazean erakusten diren hizkuntzak.', + 'display': 'Raspberry Pi pantailarako aurkezpen-aukerak.', + 'rotation': 'Pausa globalak orrialde-ziklo osoak kontrolatzen ditu; sekzio bakoitzeko pausak zenbat denbora ikusiko den zehazten du.', + 'media': 'Tokiko karpeta, ordena, doitze-modua eta banakako multimedia-fitxategien arteko pausa.', + 'diagnostics': 'Arazoak konpontzeko erregistroen xehetasun-maila.'}} +} + + +FORM_SECTIONS = [ + ('location', (('lat',), ('lon',), ('timezone',))), + ('weather', (('ds_api_key',), ('units',), ('lang',), ('ui_lang',), ('update_freq',))), + ('display', (('fullscreen',), ('12hour_disp',), ('icon_offset',))), + ('rotation', ( + ('info_pause',), ('info_delay',), + ('plugins', 'daily', 'enabled'), ('plugins', 'daily', 'pause'), + ('plugins', 'hourly', 'enabled'), ('plugins', 'hourly', 'pause'), + ('plugins', 'info', 'enabled'), ('plugins', 'info', 'pause'))), + ('media', ( + ('plugins', 'media', 'enabled'), ('plugins', 'media', 'pause'), + ('plugins', 'media', 'path'), ('plugins', 'media', 'shuffle'), + ('plugins', 'media', 'fit'), ('plugins', 'media', 'extensions'))), + ('diagnostics', (('log_level',),)), +] + +FIELD_BY_PATH = {path: (label_key, field_type) + for path, label_key, field_type in CONFIG_FORM_FIELDS} + +SELECT_OPTIONS = { + ('timezone',): [(timezone, timezone) for timezone in SUPPORTED_TIMEZONES], + ('units',): [('si', 'Metric (SI)'), ('us', 'US'), ('ca', 'Canada'), + ('uk2', 'UK'), ('auto', 'Auto')], + ('lang',): [(language, language.upper()) for language in SUPPORTED_LANGUAGES], + ('ui_lang',): [(language, language.upper()) for language in SUPPORTED_LANGUAGES], + ('plugins', 'media', 'fit'): [(mode, mode.title()) for mode in MEDIA_FIT_MODES], + ('log_level',): [(level, level) for level in + ('DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL')], +} + class ConfigWebApp: @@ -242,59 +378,301 @@ def _render_form(self, config, message=""): if message: rows.append('

{}

'.format(html.escape(message))) rows.append('') - rows.append('
{}'.format( - html.escape(self._text(config, "legend")))) - for path, label_key, field_type in CONFIG_FORM_FIELDS: - value = get_config_value(config, path) - name = field_name(path) - rows.append(''.format( - name=html.escape(name), - label=html.escape(self._label(config, label_key)))) - if field_type == "bool": - checked = " checked" if value else "" - rows.append(''.format( - html.escape(name), checked)) - else: - input_type = "number" if field_type in ("int", "float") else "text" - step = ' step="any"' if field_type == "float" else "" - rows.append(''.format( - type=input_type, - name=html.escape(name), - value=html.escape(str(value)), - step=step)) - rows.append('
') - rows.append(''.format( + rows.append('
') + for section_key, paths in FORM_SECTIONS: + rows.append('
'.format( + html.escape(section_key))) + rows.append('
') + rows.append('

{title}

'.format( + id=html.escape(section_key), + title=html.escape(self._section_title(config, section_key)))) + rows.append('?'.format( + help=html.escape( + self._section_help(config, section_key), + quote=True))) + rows.append('
') + rows.append('

{}

'.format(html.escape( + self._section_help(config, section_key)))) + rows.append('
') + for path in paths: + rows.append(self._render_field(config, path)) + rows.append('
') + if section_key == 'location': + rows.append(self._render_location_map(config)) + rows.append('
') + rows.append('
') + rows.append('
'.format( html.escape(self._text(config, "save")))) - rows.append('') - rows.append('

{}

'.format( + rows.append('{}
'.format( html.escape(self._text(config, "validate")))) + rows.append('') return self._page(self._text(config, "title"), "\n".join(rows), self._language(config)) + def _render_field(self, config, path): + label_key, field_type = FIELD_BY_PATH[path] + value = get_config_value(config, path) + name = field_name(path) + escaped_name = html.escape(name) + label = html.escape(self._label(config, label_key)) + control = '' + if field_type == "bool": + checked = " checked" if value else "" + control = ('').format( + name=escaped_name, checked=checked, label=label) + return '
{}
'.format(control) + if path in SELECT_OPTIONS: + control = self._render_select(path, value) + else: + input_type = "number" if field_type in ("int", "float") else "text" + step = ' step="any"' if field_type == "float" else "" + control = ('').format( + type=input_type, + name=escaped_name, + value=html.escape(str(value), quote=True), + step=step) + return ('
' + '{control}
').format( + name=escaped_name, label=label, control=control) + + def _render_select(self, path, value): + name = field_name(path) + options = list(SELECT_OPTIONS[path]) + option_values = [option_value for option_value, _ in options] + if value not in option_values: + options.insert(0, (value, value)) + option_html = [] + for option_value, option_label in options: + selected = " selected" if option_value == value else "" + option_html.append( + ''.format( + value=html.escape(str(option_value), quote=True), + selected=selected, + label=html.escape(str(option_label)))) + return ''.format( + html.escape(name), "".join(option_html)) + + def _render_location_map(self, config): + lat = float(get_config_value(config, ("lat",))) + lon = float(get_config_value(config, ("lon",))) + map_url = self._map_url(lat, lon) + open_url = 'https://www.openstreetmap.org/?mlat={lat}&mlon={lon}#map=12/{lat}/{lon}'.format( + lat=lat, lon=lon) + return """
+
+ {title} + {hint} + {open_map} +
+ +
""".format( + title=html.escape(self._text(config, "map_title"), quote=True), + hint=html.escape(self._text(config, "map_hint")), + open_map=html.escape(self._text(config, "open_map")), + open_url=html.escape(open_url, quote=True), + map_url=html.escape(map_url, quote=True)) + def _page(self, title, body, language="en"): return """ + {title} -

{title}

- {body} +
+
+

{title}

+

{subtitle}

+
+ {body} +
+ """.format( language=html.escape(language), title=html.escape(title), + subtitle=html.escape(TEXT.get(language, TEXT["en"])["subtitle"]), body=body) + def _section_title(self, config, key): + language = self._language(config) + return TEXT.get(language, TEXT["en"])["sections"][key] + + def _section_help(self, config, key): + language = self._language(config) + return TEXT.get(language, TEXT["en"])["help"][key] + + def _map_url(self, lat, lon): + delta = 0.03 + bbox = "{},{},{},{}".format(lon - delta, lat - delta, lon + delta, lat + delta) + return "https://www.openstreetmap.org/export/embed.html?" + urlencode({ + "bbox": bbox, + "layer": "mapnik", + "marker": "{},{}".format(lat, lon), + }) + def _coerce_value(self, raw_value, field_type): if field_type == "bool": return raw_value == "true" diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index 4da4ef0..8c58cde 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -114,6 +114,12 @@ def test_validate_config_requires_media_path_when_enabled(self): with self.assertRaises(ConfigError): validate_config(config) + def test_validate_config_rejects_unknown_timezone(self): + config = json.loads(json.dumps(VALID_CONFIG)) + config["timezone"] = "Europe/Unknown" + with self.assertRaises(ConfigError): + validate_config(config) + def test_config_watcher_detects_file_replacement(self): with tempfile.TemporaryDirectory() as tmpdir: path = os.path.join(tmpdir, "config.json") diff --git a/tests/test_config_web.py b/tests/test_config_web.py new file mode 100644 index 0000000..197e1c7 --- /dev/null +++ b/tests/test_config_web.py @@ -0,0 +1,70 @@ +import sys +import types +import unittest + + +if "cherrypy" not in sys.modules: + cherrypy = types.ModuleType("cherrypy") + cherrypy.expose = lambda function: function + sys.modules["cherrypy"] = cherrypy + +from piweatherrock.pwr_config_web import ConfigWebApp + + +VALID_CONFIG = { + "ds_api_key": "openmeteo-request-piweatherrock", + "lat": 40.299457, + "lon": -3.743399, + "units": "si", + "lang": "es", + "ui_lang": "es", + "timezone": "Europe/Madrid", + "fullscreen": True, + "12hour_disp": False, + "icon_offset": -23.5, + "update_freq": 900, + "info_pause": 60, + "info_delay": 900, + "plugins": { + "daily": {"pause": 60, "enabled": True}, + "hourly": {"pause": 60, "enabled": True}, + "info": {"pause": 300, "enabled": True}, + "media": { + "pause": 20, + "enabled": False, + "path": "", + "shuffle": False, + "fit": "contain", + "extensions": "jpg,jpeg,png,gif,bmp,mp4,mov,m4v,avi,webm", + }, + }, + "log_level": "INFO", +} + + +class ConfigWebTest(unittest.TestCase): + def test_form_groups_fields_with_help_and_map(self): + html = ConfigWebApp("config.json")._render_form(VALID_CONFIG) + + self.assertIn('
', html) + self.assertIn('title="Coordenadas y zona horaria', html) + self.assertIn('id="location-map"', html) + self.assertIn('openstreetmap.org/export/embed.html', html) + + def test_form_uses_selects_for_constrained_values(self): + html = ConfigWebApp("config.json")._render_form(VALID_CONFIG) + + self.assertIn('', html) + self.assertIn(''.format( + html.escape(self.csrf_token, quote=True))) rows.append('
') for section_key, paths in FORM_SECTIONS: rows.append('
'.format( @@ -418,29 +556,158 @@ def _render_form(self, config, message=""): id=html.escape(section_key), title=html.escape(self._section_title(config, section_key)))) rows.append('?'.format( + 'aria-label="{help}" data-tooltip="{help}">' + ''.format( help=html.escape( self._section_help(config, section_key), quote=True))) rows.append('
') rows.append('

{}

'.format(html.escape( self._section_help(config, section_key)))) - rows.append('
') - for path in paths: - rows.append(self._render_field(config, path)) - rows.append('
') if section_key == 'location': + rows.append('
') + rows.append('
') + rows.append('
') + for path in paths: + rows.append(self._render_field(config, path)) + rows.append('
') + rows.append(self._render_location_controls(config)) + rows.append('
') rows.append(self._render_location_map(config)) + rows.append('
') + elif section_key == 'rotation': + rows.append(self._render_rotation_fields(config)) + else: + grid_class = ( + "field-grid media-grid" + if section_key == 'media' else "field-grid") + rows.append('
'.format(grid_class)) + for path in paths: + rows.append(self._render_field(config, path)) + rows.append('
') rows.append('
') rows.append('') rows.append('
'.format( html.escape(self._text(config, "save")))) + rows.append('{}'.format( + html.escape(self._text(config, "validate_details")))) + rows.append('{}'.format( + html.escape(self._text(config, "test_weather")))) rows.append('{}
'.format( - html.escape(self._text(config, "validate")))) + html.escape(self._text(config, "plain_status")))) rows.append('') return self._page(self._text(config, "title"), "\n".join(rows), self._language(config)) + def _render_rotation_fields(self, config): + return """
+

{global_title}

+

{global_help}

+
{global_fields}
+
+
+

{pages_title}

+

{pages_help}

+
{page_fields}
+
""".format( + global_title=html.escape(self._text(config, "rotation_global_title")), + global_help=html.escape(self._text(config, "rotation_global_help")), + global_fields="".join( + self._render_field(config, path) for path in ROTATION_GLOBAL_FIELDS), + pages_title=html.escape(self._text(config, "rotation_pages_title")), + pages_help=html.escape(self._text(config, "rotation_pages_help")), + page_fields="".join( + self._render_field(config, path) for path in ROTATION_PAGE_FIELDS)) + + def _render_validation(self, config): + checks = [ + (True, "Configuration file", self.config_file), + (True, "Platform", "{} / Python {}".format( + platform.system(), platform.python_version())), + ] + ffmpeg = shutil.which("ffmpeg") + checks.append(( + bool(ffmpeg), + "ffmpeg", + ffmpeg or "Not found on PATH; local video playback will show a warning.")) + + media_config = config.get("plugins", {}).get("media", {}) + media_path = media_config.get("path", "") + expanded_media_path = expand_config_path(media_path) if media_path else "" + if media_config.get("enabled"): + checks.append(( + bool(expanded_media_path and os.path.isdir(expanded_media_path)), + "Local media folder", + expanded_media_path or "Missing path for enabled media page.")) + else: + checks.append(( + None, + "Local media folder", + "Media page disabled; folder existence is not required.")) + + return self._render_status_items(checks, self._language(config)) + + def _render_weather_test(self, config, language): + params = { + "latitude": config["lat"], + "longitude": config["lon"], + "timezone": config["timezone"], + "forecast_days": 1, + "current_weather": "true", + } + url = OPEN_METEO_FORECAST_URL + "?" + urlencode(params) + try: + with urlopen(url, timeout=10) as response: + payload = json.loads(response.read().decode("utf-8")) + if "current_weather" not in payload: + raise ValueError("Open-Meteo response did not include current_weather") + weather = payload["current_weather"] + detail = "{} Temperature: {}. Wind: {}.".format( + self._text_for_language(language, "test_weather_ok"), + weather.get("temperature", "n/a"), + weather.get("windspeed", "n/a")) + checks = [(True, "Open-Meteo", detail)] + except Exception as exc: + checks = [(False, "Open-Meteo", str(exc))] + return self._render_status_items(checks, language) + + def _valid_save_request(self, config, params): + request = getattr(cherrypy, "request", None) + method = getattr(request, "method", "POST") + if method != "POST": + return False + return secrets.compare_digest( + params.get("csrf_token", ""), + self.csrf_token) + + def _render_status_items(self, checks, language): + rows = ['
'] + rows.append('

{}

'.format(html.escape( + self._text_for_language(language, "runtime_status")))) + rows.append('
    ') + for ok, label, detail in checks: + status_class = "warn" if ok is None else "ok" if ok else "fail" + status_text = "WARN" if ok is None else "OK" if ok else "ERROR" + rows.append( + '
  • {label}' + '{status_text}' + '

    {detail}

  • '.format( + status_class=status_class, + label=html.escape(label), + status_text=status_text, + detail=html.escape(str(detail)))) + rows.append('
') + return "\n".join(rows) + + def _back_link(self, language): + return '

{}

'.format( + html.escape(self._text_for_language(language, "back_to_config"))) + + def _field_hint(self, config, path): + if path == ('plugins', 'media', 'path'): + return self._text(config, "media_path_hint") + return "" + def _render_field(self, config, path): label_key, field_type = self._field_definition(path) value = get_config_value(config, path) @@ -468,9 +735,14 @@ def _render_field(self, config, path): name=escaped_name, value=html.escape(str(value), quote=True), step=step) + hint = self._field_hint(config, path) + hint_html = ( + '

{}

'.format(html.escape(hint)) + if hint else "") return ('
' - '{control}
').format( - name=escaped_name, label=label, control=control) + '{control}{hint}').format( + name=escaped_name, label=label, control=control, + hint=hint_html) def _render_select(self, config, path, value): name = field_name(path) @@ -490,14 +762,12 @@ def _render_select(self, config, path, value): return ''.format( html.escape(name), "".join(option_html)) - def _render_location_map(self, config): + def _render_location_controls(self, config): lat = float(get_config_value(config, ("lat",))) lon = float(get_config_value(config, ("lon",))) - map_url = self._map_url(lat, lon) open_url = 'https://www.openstreetmap.org/?mlat={lat}&mlon={lon}#map=12/{lat}/{lon}'.format( lat=lat, lon=lon) - return """
-
+ return """
{title} {hint} @@ -505,17 +775,32 @@ def _render_location_map(self, config): {preset_options} + {open_map} -
-
""".format( title=html.escape(self._text(config, "map_title"), quote=True), hint=html.escape(self._text(config, "map_hint")), preset_label=html.escape(self._text(config, "location_preset")), custom_label=html.escape(self._text(config, "location_custom")), preset_options=self._render_location_preset_options(lat, lon), + pick_start=html.escape(self._text(config, "map_pick_start")), + pick_active=html.escape(self._text(config, "map_pick_active")), open_map=html.escape(self._text(config, "open_map")), - open_url=html.escape(open_url, quote=True), + open_url=html.escape(open_url, quote=True)) + + def _render_location_map(self, config): + lat = float(get_config_value(config, ("lat",))) + lon = float(get_config_value(config, ("lon",))) + map_url = self._map_url(lat, lon) + return """
+ + +
""".format( + title=html.escape(self._text(config, "map_title"), quote=True), + pick_active=html.escape(self._text(config, "map_pick_active"), quote=True), map_url=html.escape(map_url, quote=True)) def _render_location_preset_options(self, current_lat, current_lon): @@ -650,14 +935,64 @@ def _page(self, title, body, language="en"): border: 1px solid var(--help-border); border-radius: 999px; color: var(--brand); + cursor: pointer; display: inline-flex; font-weight: 800; + gap: .15rem; height: 1.65rem; justify-content: center; + position: relative; width: 1.65rem; }} + .help-icon::after {{ + background: var(--card); + border: 1px solid var(--help-border); + border-radius: .7rem; + box-shadow: var(--shadow); + color: var(--text); + content: attr(data-tooltip); + font-size: .92rem; + font-weight: 600; + line-height: 1.35; + opacity: 0; + padding: .9rem 1rem; + pointer-events: none; + position: absolute; + right: 0; + text-align: left; + top: calc(100% + .55rem); + transform: translateY(-.25rem); + transition: opacity .15s, transform .15s; + visibility: hidden; + width: min(22rem, 75vw); + z-index: 20; + }} + .help-icon:hover::after, + .help-icon:focus::after {{ + opacity: 1; + transform: translateY(0); + visibility: visible; + }} .section-help {{ color: var(--muted); margin: .45rem 0 1rem; }} + .section-subgroup {{ + border-top: 1px solid var(--border); + margin-top: 1rem; + padding-top: 1rem; + }} + .section-subgroup:first-of-type {{ border-top: 0; margin-top: 0; padding-top: 0; }} + h3 {{ font-size: 1rem; margin: 0 0 .35rem; }} + .section-subgroup p, .field-hint {{ color: var(--muted); margin: 0 0 .85rem; }} + .field-hint {{ font-size: .84rem; margin: .35rem 0 0; }} .field-grid {{ display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); }} + .media-grid {{ align-items: start; grid-template-columns: repeat(2, minmax(14rem, 1fr)); }} + .media-grid .field:first-child, + .media-grid .field:last-child {{ grid-column: 1 / -1; }} + .media-grid .field-checkbox {{ + align-items: start; + min-height: auto; + padding-top: 1.55rem; + }} + .media-grid .toggle {{ min-height: 3rem; }} label {{ color: var(--label-color); display: block; font-size: .92rem; font-weight: 700; margin-bottom: .35rem; }} input, select {{ background: var(--input-bg); @@ -688,19 +1023,43 @@ def _page(self, title, body, language="en"): width: 100%; }} .toggle input {{ accent-color: var(--brand); height: 1.1rem; width: 1.1rem; }} + .location-layout {{ + align-items: stretch; + display: grid; + gap: 1rem; + grid-template-columns: minmax(16rem, 22rem) minmax(0, 1fr); + }} + .location-controls {{ + display: flex; + flex-direction: column; + gap: 1rem; + }} + .location-field-grid {{ grid-template-columns: 1fr; }} .map-panel {{ border: 1px solid var(--border); border-radius: .9rem; - display: grid; - gap: 1rem; - grid-template-columns: minmax(12rem, 18rem) 1fr; - margin-top: 1rem; + min-height: 30rem; overflow: hidden; + position: relative; }} .map-copy {{ background: var(--map-bg); display: flex; flex-direction: column; gap: .5rem; padding: 1rem; }} .map-copy span {{ color: var(--muted); }} .map-copy a, .status-link {{ color: var(--brand); font-weight: 700; text-decoration: none; }} + .map-pick-toggle {{ + background: var(--brand); + border: 0; + border-radius: .7rem; + color: white; + cursor: pointer; + font: inherit; + font-weight: 800; + padding: .72rem .85rem; + text-align: left; + width: 100%; + }} + .map-pick-toggle.active {{ background: #dc2626; }} iframe {{ border: 0; min-height: 16rem; width: 100%; }} + .map-panel iframe {{ height: 100%; min-height: 30rem; }} .actions {{ align-items: center; display: flex; flex-wrap: wrap; gap: 1rem; margin-top: 1.5rem; }} button {{ background: var(--brand); @@ -714,6 +1073,43 @@ def _page(self, title, body, language="en"): transition: background .2s; }} button:hover {{ background: var(--brand-dark); }} + .map-picker {{ + background: transparent; + border: 0; + border-radius: 0; + cursor: crosshair; + inset: 0; + padding: 0; + pointer-events: none; + position: absolute; + width: auto; + }} + .map-panel.picking .map-picker {{ pointer-events: auto; }} + .map-picker:hover {{ background: rgba(37, 99, 235, .04); }} + .map-panel.picking .map-picker::after {{ + background: rgba(22, 32, 51, .78); + border-radius: 999px; + color: white; + content: "\\1F4CC"; + font-size: 2rem; + left: 50%; + line-height: 1; + padding: .55rem; + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + }} + .button-link {{ + background: transparent; + border: 1px solid var(--brand); + border-radius: .8rem; + color: var(--brand); + display: inline-block; + font-weight: 800; + padding: .78rem 1.05rem; + text-decoration: none; + }} + .button-link:hover {{ background: rgba(37, 99, 235, .10); }} .message {{ background: var(--msg-bg); border: 1px solid var(--msg-border); @@ -721,10 +1117,41 @@ def _page(self, title, body, language="en"): color: var(--msg-text); padding: .9rem 1rem; }} + .status-panel {{ + background: var(--card); + border: 1px solid var(--border); + border-radius: 1rem; + box-shadow: 0 10px 30px rgba(22, 32, 51, .06); + padding: 1.25rem; + }} + .status-list {{ display: grid; gap: .8rem; list-style: none; margin: 1rem 0 0; padding: 0; }} + .status-list li {{ + border: 1px solid var(--border); + border-left-width: .35rem; + border-radius: .8rem; + padding: .85rem; + }} + .status-list li.ok {{ border-left-color: #16a34a; }} + .status-list li.warn {{ border-left-color: #eab308; }} + .status-list li.fail {{ border-left-color: #dc2626; }} + .status-list p {{ color: var(--muted); margin: .35rem 0 0; }} + .status-badge {{ + border: 1px solid var(--border); + border-radius: 999px; + float: right; + font-size: .76rem; + font-weight: 800; + padding: .15rem .5rem; + }} @media (max-width: 720px) {{ main {{ padding: 1rem; }} .hero {{ padding: 1.4rem; flex-direction: column; gap: 1rem; }} - .map-panel {{ grid-template-columns: 1fr; }} + .location-layout, + .media-grid {{ grid-template-columns: 1fr; }} + .media-grid .field:first-child, + .media-grid .field:last-child {{ grid-column: auto; }} + .media-grid .field-checkbox {{ padding-top: 0; }} + .map-panel, .map-panel iframe {{ min-height: 22rem; }} }} @media (prefers-color-scheme: dark) {{ html:not([data-theme="light"]) {{ @@ -789,6 +1216,9 @@ def _page(self, title, body, language="en"): var frame = document.getElementById('location-map'); var link = document.getElementById('open-map-link'); var preset = document.getElementById('location-preset'); + var picker = document.getElementById('map-picker'); + var pickToggle = document.getElementById('map-pick-toggle'); + var mapPanel = picker ? picker.closest('.map-panel') : null; function mapUrl(latitude, longitude) {{ var delta = {map_delta}; var left = longitude - delta; @@ -811,9 +1241,50 @@ def _page(self, title, body, language="en"): '&mlon=' + encodeURIComponent(longitude) + '#map=12/' + encodeURIComponent(latitude) + '/' + encodeURIComponent(longitude); }} + function setPickedLocation(latitude, longitude) {{ + lat.value = latitude.toFixed(6); + lon.value = longitude.toFixed(6); + if (preset) {{ + preset.value = ''; + }} + updateMap(); + }} + function setPickMode(active) {{ + if (!pickToggle || !mapPanel) {{ + return; + }} + mapPanel.classList.toggle('picking', active); + pickToggle.classList.toggle('active', active); + pickToggle.textContent = (active ? '\\uD83D\\uDCCC ' + pickToggle.dataset.active : '\\uD83D\\uDCCC ' + pickToggle.dataset.start); + }} if (lat && lon && frame && link) {{ lat.addEventListener('change', updateMap); lon.addEventListener('change', updateMap); + if (pickToggle) {{ + pickToggle.addEventListener('click', function () {{ + setPickMode(!(mapPanel && mapPanel.classList.contains('picking'))); + }}); + }} + if (picker) {{ + picker.addEventListener('click', function (event) {{ + if (!mapPanel || !mapPanel.classList.contains('picking')) {{ + return; + }} + var centerLat = parseFloat(lat.value); + var centerLon = parseFloat(lon.value); + if (isNaN(centerLat) || isNaN(centerLon)) {{ + return; + }} + var rect = picker.getBoundingClientRect(); + var delta = {map_delta}; + var clickX = (event.clientX - rect.left) / rect.width; + var clickY = (event.clientY - rect.top) / rect.height; + var pickedLon = centerLon - delta + (clickX * delta * 2); + var pickedLat = centerLat + delta - (clickY * delta * 2); + setPickedLocation(pickedLat, pickedLon); + setPickMode(false); + }}); + }} if (preset) {{ preset.addEventListener('change', function () {{ if (!preset.value) {{ @@ -884,6 +1355,9 @@ def _timezone_options(self): def _text(self, config, key): language = self._language(config) + return self._text_for_language(language, key) + + def _text_for_language(self, language, key): language_text = TEXT[language] if language in TEXT else {} if key in language_text: return language_text[key] @@ -909,6 +1383,24 @@ def _set_no_store_headers(self): response = getattr(cherrypy, "response", None) if response is not None: response.headers["Cache-Control"] = "no-store, max-age=0" + response.headers["Content-Security-Policy"] = ( + "default-src 'self'; " + "style-src 'self' 'unsafe-inline'; " + "script-src 'self' 'unsafe-inline'; " + "frame-src https://www.openstreetmap.org; " + "img-src 'self' data: https://*.tile.openstreetmap.org; " + "connect-src 'self'; " + "form-action 'self'; " + "frame-ancestors 'none'; " + "base-uri 'self'") + response.headers["Referrer-Policy"] = "no-referrer" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + + def _set_response_status(self, status): + response = getattr(cherrypy, "response", None) + if response is not None: + response.status = status def main(): @@ -919,17 +1411,42 @@ def main(): help='Host to bind to; defaults to localhost') parser.add_argument('--port', default=8888, type=int, help='Port to bind to') + parser.add_argument('--open', action='store_true', + help='Open the config UI in the default browser') args = parser.parse_args() config_file = os.path.abspath(args.config) + available, port_error = _port_available(args.host, args.port) + if not available: + parser.exit(70, "Error: port {} is not available on {}: {}\n".format( + args.port, args.host, port_error)) + cherrypy.config.update({ 'server.socket_host': args.host, 'server.socket_port': args.port, }) - print("PiWeatherRock config UI: http://{}:{}".format( - args.host, args.port)) + url = "http://{}:{}".format(_browser_host(args.host), args.port) + print("PiWeatherRock config UI: {}".format(url)) + if args.open: + webbrowser.open(url) cherrypy.quickstart(ConfigWebApp(config_file)) +def _browser_host(host): + if host in ("0.0.0.0", "::", ""): + return "127.0.0.1" + return host + + +def _port_available(host, port): + family = socket.AF_INET6 if ":" in host and host != "0.0.0.0" else socket.AF_INET + with socket.socket(family, socket.SOCK_STREAM) as probe: + try: + probe.bind((host, port)) + except OSError as exc: + return False, exc + return True, None + + if __name__ == '__main__': main() diff --git a/piweatherrock/weather.py b/piweatherrock/weather.py index 671c061..6007664 100644 --- a/piweatherrock/weather.py +++ b/piweatherrock/weather.py @@ -59,36 +59,7 @@ def __init__(self, config_file): self.weather = {} self.get_forecast() - if platform.system() == 'Darwin': - pygame.display.init() - driver = pygame.display.get_driver() - self.log.debug(f"Using the {driver} driver.") - else: - # Based on "Python GUI in Linux frame buffer" - # http://www.karoltomala.com/blog/?p=679 - disp_no = os.getenv("DISPLAY") - if disp_no: - self.log.debug(f"X Display = {disp_no}") - - # Check which frame buffer drivers are available - # Start with fbcon since directfb hangs with composite output - drivers = ['x11', 'fbcon', 'directfb', 'svgalib'] - found = False - for driver in drivers: - # Make sure that SDL_VIDEODRIVER is set - if not os.getenv('SDL_VIDEODRIVER'): - os.putenv('SDL_VIDEODRIVER', driver) - try: - pygame.display.init() - except pygame.error: - self.log.debug("Driver: {driver} failed.") - continue - found = True - break - - if not found: - self.log.critical("No suitable video driver found!") - sys.exit(1) + self._init_display() size = (pygame.display.Info().current_w, pygame.display.Info().current_h) @@ -108,6 +79,50 @@ def __init__(self, config_file): self.time_date_y_position = 8 self.time_date_small_y_position = 18 + def _init_display(self): + system = platform.system() + if system != 'Linux': + pygame.display.init() + driver = pygame.display.get_driver() + self.log.debug("Using the %s driver on %s.", driver, system) + return + + # Based on "Python GUI in Linux frame buffer" + # http://www.karoltomala.com/blog/?p=679 + disp_no = os.getenv("DISPLAY") + if disp_no: + self.log.debug(f"X Display = {disp_no}") + + configured_driver = os.getenv('SDL_VIDEODRIVER') + if configured_driver: + try: + pygame.display.init() + except pygame.error: + self.log.critical( + "Configured SDL video driver %s failed.", + configured_driver) + sys.exit(1) + self.log.debug("Using the %s driver.", pygame.display.get_driver()) + return + + # Check which frame buffer drivers are available. + # Start with x11, then fall back to framebuffer drivers for Raspberry Pi. + drivers = ['x11', 'fbcon', 'directfb', 'svgalib'] + for driver in drivers: + os.environ['SDL_VIDEODRIVER'] = driver + try: + pygame.display.init() + except pygame.error: + pygame.display.quit() + self.log.debug("Driver %s failed.", driver) + continue + self.log.debug("Using the %s driver.", pygame.display.get_driver()) + return + + os.environ.pop('SDL_VIDEODRIVER', None) + self.log.critical("No suitable video driver found!") + sys.exit(1) + def __del__(self): "Destructor to make sure pygame shuts down, etc." diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index 8c58cde..b24a264 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -57,6 +57,56 @@ def test_load_config_rejects_invalid_json(self): finally: os.remove(path) + def test_load_config_reads_utf8_paths(self): + with tempfile.TemporaryDirectory() as tmpdir: + media_dir = os.path.join(tmpdir, "Imágenes") + os.mkdir(media_dir) + path = os.path.join(tmpdir, "config.json") + config = dict(VALID_CONFIG) + config["plugins"] = dict(VALID_CONFIG["plugins"]) + config["plugins"]["media"] = dict(VALID_CONFIG["plugins"]["media"]) + config["plugins"]["media"]["enabled"] = True + config["plugins"]["media"]["path"] = media_dir + with open(path, "w", encoding="utf-8") as f: + json.dump(config, f) + + loaded = load_config(path) + + self.assertEqual(loaded["plugins"]["media"]["path"], media_dir) + + def test_sample_config_defaults_to_getafe(self): + sample_path = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "piweatherrock", + "config.json-sample") + config = load_config(sample_path) + + self.assertEqual(config["timezone"], "Europe/Madrid") + self.assertAlmostEqual(config["lat"], 40.30825) + self.assertAlmostEqual(config["lon"], -3.732393) + self.assertEqual(config["plugins"]["media"]["fit"], "cover") + + def test_media_path_validation_expands_environment_variables(self): + with tempfile.TemporaryDirectory() as tmpdir: + old_value = os.environ.get("PWR_TEST_MEDIA_DIR") + os.environ["PWR_TEST_MEDIA_DIR"] = tmpdir + try: + config = dict(VALID_CONFIG) + config["plugins"] = dict(VALID_CONFIG["plugins"]) + config["plugins"]["media"] = dict( + VALID_CONFIG["plugins"]["media"]) + config["plugins"]["media"]["enabled"] = True + config["plugins"]["media"]["path"] = ( + "%PWR_TEST_MEDIA_DIR%" + if os.name == "nt" else "$PWR_TEST_MEDIA_DIR") + + self.assertTrue(validate_config(config)) + finally: + if old_value is None: + os.environ.pop("PWR_TEST_MEDIA_DIR", None) + else: + os.environ["PWR_TEST_MEDIA_DIR"] = old_value + def test_write_config_atomic_creates_backup_and_valid_file(self): with tempfile.TemporaryDirectory() as tmpdir: path = os.path.join(tmpdir, "config.json") diff --git a/tests/test_config_web.py b/tests/test_config_web.py index f3b9517..85d7e38 100644 --- a/tests/test_config_web.py +++ b/tests/test_config_web.py @@ -5,7 +5,7 @@ from unittest import mock -def _config_web_app_class(): +def _config_web_module(): try: import cherrypy # noqa: F401 except ImportError: @@ -15,10 +15,11 @@ def _config_web_app_class(): module = importlib.import_module("piweatherrock.pwr_config_web") else: module = importlib.import_module("piweatherrock.pwr_config_web") - return module.ConfigWebApp + return module -ConfigWebApp = _config_web_app_class() +ConfigWebModule = _config_web_module() +ConfigWebApp = ConfigWebModule.ConfigWebApp VALID_CONFIG = { @@ -68,8 +69,14 @@ def test_form_groups_fields_with_help_and_map(self): self.assertIn('class="help-icon" tabindex="0" role="button"', html) self.assertIn('
', html) self.assertIn('id="location-map"', html) + self.assertIn('id="map-picker"', html) + self.assertIn('id="map-pick-toggle"', html) + self.assertIn('class="location-layout"', html) self.assertIn('id="location-preset"', html) self.assertIn('openstreetmap.org/export/embed.html', html) + self.assertIn('pointer-events: none', html) + self.assertIn('.map-panel.picking .map-picker', html) + self.assertIn("picker.addEventListener('click'", html) self.assertNotIn("', html) self.assertIn('