diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfdb8b7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/.gitignore b/.gitignore index 2e63cf1..3aa052d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ config.py config.json *.swp .log +*.bak # Byte-compiled / optimized / DLL files __pycache__/ 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 cafd906..d27ab8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Change log +## [3.0.0](https://github.com/carloshm/PiWeatherRock) - 2026-05-08 + +- 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 +- Migrated packaging from `setup.py` to `pyproject.toml` (PEP 621) +- Added `timezone` to configuration +- Updated project metadata and homepage to carloshm fork +- Added current console entry points: `pwr-ui`, `pwr-config-web`, and `pwr-config-upgrade` +- Added a guided local web configuration UI with map-based location selection, theme support, runtime validation, and Open-Meteo test +- Added hot-reload support so valid JSON configuration changes can be applied while `pwr-ui` is running +- Added local media screen support for images and short videos, including fit modes and `ffmpeg`-based video playback +- Improved cross-platform support for Windows, macOS, and Linux display initialization +- Made video frame reading compatible with Windows by replacing pipe `select()` usage with a background reader queue +- Added safer config web handling with CSRF-protected saves, fixed success messages, and security headers +- Added UTF-8 config reading/writing and `~`/environment variable expansion for local media paths +- Updated the default sample/runtime configuration for Getafe (`Europe/Madrid`) and `cover` media fit +- Updated installation and usage documentation, including Windows PowerShell instructions and the corrected MIT license link + ## [2.1.0](https://github.com/genebean/PiWeatherRock/tree/2.1.0) - Add option for 24h time @@ -44,4 +65,3 @@ - First stable release that includes proper documentation. The documentation now lives at https://piweatherrock.technicalissues.us - 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/README.md b/README.md index bcc1683..20a48fa 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,192 @@ # PiWeatherRock -![GitHub](https://img.shields.io/github/license/genebean/PiWeatherRock) -![PyPI](https://img.shields.io/pypi/v/piweatherrock) +[![License: MIT](https://img.shields.io/github/license/carloshm/PiWeatherRock)](LICENSE) -PiWeatherRock displays local weather on (almost) any screen you connect to a Raspberry Pi. It also works on other platforms, including macOS. +PiWeatherRock displays local weather on (almost) any screen you connect to a Raspberry Pi. It also works on other platforms, including Windows and 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. + +> **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 (`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. +- `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`. + 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, +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. +Add `--open` to launch the default browser automatically. If the chosen port is +already in use, the command exits with a clear error instead of a server stack +trace. +The same UI exposes the screen selection, display time, and local media folder +settings. The location map can be panned and zoomed normally; use the pin button +when you want a map click to update latitude and longitude. The interface +supports both light and dark themes with an automatic toggle based on system +preference. It also includes runtime validation, CSRF-protected saves, security +headers, and an Open-Meteo test using the configured latitude, longitude, and +timezone. +See the expanded visual guide in [`docs/README.md`](docs/README.md#aplicaci%C3%B3n-web-de-configuraci%C3%B3n). + +## Installation + +PiWeatherRock is packaged with `pyproject.toml` and installs the current +console commands `pwr-ui`, `pwr-config-web`, 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 +chmod +x install.sh +./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. The sample +configuration defaults to Getafe, Spain (`Europe/Madrid`): + +```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 (macOS/Linux) + +```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 +``` + +### Manual or development installation (Windows) + +Use PowerShell from the repository root: + +```powershell +py -m venv .venv +.\.venv\Scripts\Activate.ps1 +py -m pip install --upgrade pip setuptools wheel +py -m pip install . +Copy-Item piweatherrock\config.json-sample piweatherrock\piweatherrock-config.json +# Edit piweatherrock\piweatherrock-config.json before running. +pwr-ui -c .\piweatherrock\piweatherrock-config.json +``` + +The `pwr-config-web` configuration UI works the same way on Windows, macOS, +and Linux. Local image playback uses pygame on all supported platforms. Local +video playback requires `ffmpeg` to be installed and available on `PATH`. Local +media paths may use `~` or environment variables, but the expanded folder must +exist before enabling the media page. + +If your shell reports `pwr-config-web: command not found`, the PiWeatherRock +package is not installed in the currently active virtual environment. From the +repository root, activate the environment and reinstall the package: + +```bash +source ~/pwr-env/bin/activate +python3 -m pip install . +``` + +Then verify that `pwr-config-web` is on `PATH` with `command -v pwr-config-web`. + +See [`docs/`](docs/) for an application walkthrough with screenshots. ## 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/*` - `twine upload dist/*` - Create a git tag and push it + +## Local Development process + +```bash +python3 -m venv env_name +source env_name/bin/activate +``` + +```bash +git clone https://github.com/carloshm/PiWeatherRock.git +cd PiWeatherRock +git pull # for any additional external change after a while +``` + +Make changes + +```bash +git add . +git commit -m "changes description" +git push origin main +``` + +## Run changes + +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 . +pwr-ui -c ./piweatherrock/piweatherrock-config.json +``` + +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 + +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/docs/README.md b/docs/README.md new file mode 100644 index 0000000..7eac335 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,220 @@ +# 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, información general y una pantalla opcional de medios locales. + +## Instalación actual resumida + +La instalación vigente usa el empaquetado definido en `pyproject.toml`. + +En Raspberry Pi/Linux se puede usar el script del repositorio: + +```bash +chmod +x install.sh +./install.sh Europe/Madrid +``` + +El script instala dependencias del sistema, crea el entorno virtual `~/pwr-env` e instala el paquete con `pip install .`. + +Después, activa el entorno, crea la configuración desde la plantilla y ejecuta el entry point actual: + +```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 +``` + +Para una instalación manual o de desarrollo en macOS/Linux: + +```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 +``` + +En Windows, usa PowerShell desde la raíz del repositorio: + +```powershell +py -m venv .venv +.\.venv\Scripts\Activate.ps1 +py -m pip install --upgrade pip setuptools wheel +py -m pip install . +Copy-Item piweatherrock\config.json-sample piweatherrock\piweatherrock-config.json +pwr-ui -c .\piweatherrock\piweatherrock-config.json +``` + +La aplicación principal y la configuración web usan los mismos entry points en +Windows, macOS y Linux. La reproducción de imágenes locales es multiplataforma; +la reproducción de vídeos locales requiere que `ffmpeg` esté instalado y +disponible en el `PATH`. + +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 --open + ``` + + Si tenías scripts antiguos con `pwr-webconfig`, puedes sustituirlos por + `pwr-config-web`, que es ahora el único punto de entrada para la + configuración web. + + Si la terminal muestra `pwr-config-web: orden no encontrada`, el paquete no + está instalado en el entorno virtual activo. Activa el entorno y reinstala el + paquete desde la raíz del repositorio: + + ```bash + source ~/pwr-env/bin/activate + python3 -m pip install . + command -v pwr-config-web + ``` + +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. + +En la sección **Ubicación y zona horaria**, el mapa se puede mover y ampliar +normalmente. Para cambiar las coordenadas con el ratón, pulsa **Colocar +chincheta en el mapa** y después haz clic en el punto deseado. + +### Qué se puede configurar + +La pantalla web expone los campos principales definidos para `piweatherrock/piweatherrock-config.json`: + +- **Ubicación:** `lat`, `lon` y `timezone`. La plantilla incluida usa Getafe + como ubicación inicial (`40.30825`, `-3.732393`, `Europe/Madrid`). +- **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`. +- **Pausas de rotación:** los controles globales se muestran separados de la + duración visible de cada página (`daily`, `hourly`, `info` y `media`) para + ver claramente qué pausa corresponde a cada pantalla. +- **Medios locales:** carpeta, orden aleatorio, modo de ajuste y extensiones + permitidas para `plugins.media`. Las rutas pueden usar `~` o variables de + entorno, pero la carpeta expandida debe existir antes de activar la página. +- **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 en UTF-8. Si ya existía un fichero de configuración, se conserva una copia con sufijo `.bak`. + +El botón **Revisar configuración** abre una vista con la configuración cargada, +la plataforma detectada, disponibilidad de `ffmpeg` y estado de la carpeta de +medios. El botón **Probar Open-Meteo** hace una petición real con la latitud, +longitud y zona horaria configuradas. + +El enlace **Estado simple** 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. + +El guardado está protegido con token CSRF y los mensajes de éxito no reflejan +texto arbitrario desde la URL; la aplicación también envía cabeceras de +seguridad como CSP, `Referrer-Policy`, `X-Content-Type-Options` y +`X-Frame-Options`. + +### 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 +``` + +Puedes añadir `--open` para abrir el navegador automáticamente. Si el puerto +elegido ya está ocupado, el comando lo indica con un error claro antes de +arrancar CherryPy. + +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. + +![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) + +## 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`, que debe existir antes de activar `plugins.media.enabled`, los escala a la pantalla y permite elegir el modo de ajuste. La plantilla usa `cover` por defecto: + +- `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. + +## 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`, `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 interfaz soporta tema claro y oscuro, con un botón de alternancia y detección automática de la preferencia del sistema. La configuración se recarga automáticamente en la UI principal cuando el JSON actualizado es válido. 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 + + 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 + + diff --git a/install.sh b/install.sh index 98b3ed2..82d2794 100755 --- a/install.sh +++ b/install.sh @@ -1,19 +1,42 @@ #!/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..." +python3 -m pip install --upgrade pip setuptools wheel +python3 -m pip install . + +echo "==> Verifying console commands..." +if ! command -v pwr-ui >/dev/null 2>&1 || ! command -v pwr-config-web >/dev/null 2>&1; then + echo "ERROR: PiWeatherRock console commands were not installed in the active environment." >&2 + echo "Activate the environment with 'source ~/pwr-env/bin/activate' and run 'python3 -m pip install .' from the repository root." >&2 + exit 1 +fi + +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" +echo "Configure from a browser with: pwr-config-web -c ./piweatherrock/piweatherrock-config.json" 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..ec4bc35 --- /dev/null +++ b/piweatherrock/climate/data.py @@ -0,0 +1,66 @@ +# 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 + _handlers = { + 'alerts': Alerts, + 'flags': Flags, + } + if name in _handlers: + return setval(_handlers[name](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 weather 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..6e7d795 --- /dev/null +++ b/piweatherrock/climate/forecast.py @@ -0,0 +1,105 @@ +# forecast.py +from __future__ import print_function +from builtins import super + +import json +import sys +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 * + +# 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 + + 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, + 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) + raise requests.exceptions.HTTPError('Bad response') + + return super().__init__(openmeteo_to_darksky(response.text, queries["lang"])) diff --git a/piweatherrock/climate/openmeteo.py b/piweatherrock/climate/openmeteo.py new file mode 100644 index 0000000..9cc22d0 --- /dev/null +++ b/piweatherrock/climate/openmeteo.py @@ -0,0 +1,427 @@ +# openmeteo.py + +import json +import datetime +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): + return WEATHER_TRANSLATIONS.get(wmocode, {}).get( + lang, UNKNOWN_WEATHER.get(lang, UNKNOWN_WEATHER["en"])) + +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: 'chancesleet', + 57: 'sleet', + 61: 'chancerain', + 63: 'rain', + 65: 'rain', + 66: 'chancesleet', + 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) + + 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"] = { + "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": 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, + "uvIndex": daily_data["uv_index_max"][i], + "uvIndexTime": 0, + "visibility": hourly_visibility, + "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] / 100, + "precipType": "rain", + "temperature": json_data["current_weather"]["temperature"], + "apparentTemperature": json_data["current_weather"]["temperature"], + "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, + "uvIndex": daily_data["uv_index_max"][0], + "visibility": hourly_visibility, + "ozone": 0 + } + + return darksky_data diff --git a/piweatherrock/config.json-sample b/piweatherrock/config.json-sample index 4bf6d63..a4e81d9 100644 --- a/piweatherrock/config.json-sample +++ b/piweatherrock/config.json-sample @@ -1,10 +1,12 @@ { - "version": "1.4.0", - "ds_api_key": "API_KEY_HERE", - "lat": 0.112358, - "lon": 0.246810, - "units": "us", + "version": "3.0.0", + "ds_api_key": "openmeteo-request-piweatherrock", + "lat": 40.30825, + "lon": -3.732393, + "units": "si", "lang": "en", + "ui_lang": "en", + "timezone": "Europe/Madrid", "fullscreen": true, "icon_offset": -23.5, "update_freq": 300, @@ -20,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": "cover", + "extensions": "jpg,jpeg,png,gif,bmp,mp4,mov,m4v,avi,webm" } } } diff --git a/piweatherrock/config_manager.py b/piweatherrock/config_manager.py new file mode 100644 index 0000000..6a08798 --- /dev/null +++ b/piweatherrock/config_manager.py @@ -0,0 +1,407 @@ +# -*- coding: utf-8 -*- +"""Configuration loading, validation, and hot-reload helpers.""" + +import copy +import json +import os +import shutil +import tempfile +import time + +from pytz import common_timezones + + +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, +} + +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": "cover", + "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") +SUPPORTED_TIMEZONES = tuple(common_timezones) + +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",), +} + + +CONFIG_FORM_FIELDS = [ + (("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"), + (("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"), +] + + +def load_config(config_file): + """Load and validate a PiWeatherRock JSON configuration.""" + try: + with open(config_file, "r", encoding="utf-8") as f: + config = json.load(f) + except (IOError, ValueError) as exc: + raise ConfigError("Could not load config file '{}': {}".format( + config_file, exc)) + + normalized = normalize_config(config) + validate_config(normalized) + return normalized + + +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): + 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 + 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)) + if plugin_name == "media": + _validate_media_plugin(plugin_config, errors) + + _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) + _validate_language(config, "lang", errors) + _validate_language(config, "ui_lang", errors) + _validate_timezone(config, errors) + + if isinstance(plugins, dict): + 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)) + + 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) + 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.""" + config = normalize_config(config) + 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", encoding="utf-8") 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 * 1e9)) + 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 _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 _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") + 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(expand_config_path(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): + extensions = [] + for part in value.split(","): + extension = part.strip().lower().lstrip(".") + if extension: + extensions.append(extension) + return extensions + + +def expand_config_path(path): + """Expand user and environment variables in configured local paths.""" + return os.path.abspath(os.path.expandvars(os.path.expanduser(path))) + + +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/intl/__init__.py b/piweatherrock/intl/__init__.py new file mode 100644 index 0000000..992331a --- /dev/null +++ b/piweatherrock/intl/__init__.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021 Carlos de Huerta +# Distributed under the MIT License (https://opensource.org/licenses/MIT) + +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 babel import Locale +from babel.dates import LOCALTZ, get_timezone_name, get_timezone + +class intl: + """ + This class assists in the internationalization and localization Pi Weather Rock data + through the use of python i18n and Babel. + """ + + 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): + 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=Locale.parse(ui_lang)).title() + else: + return format_datetime(datetime, "EEE, MMM dd hh:mm", locale=Locale.parse(ui_lang)).title() + + def get_ampm(self, ui_lang, datetime): + 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) + label = 'piweatherrock.' + text + + if params is None: + return i18n.t(label) + else: + 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..010d901 --- /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 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.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..51e17fb --- /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 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 new file mode 100644 index 0000000..763050d --- /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 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 new file mode 100644 index 0000000..f0a8836 --- /dev/null +++ b/piweatherrock/intl/data/piweatherrock.eu.json @@ -0,0 +1,19 @@ +{ + "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.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..6e25b10 --- /dev/null +++ b/piweatherrock/intl/data/piweatherrock.gl.json @@ -0,0 +1,19 @@ +{ + "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/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/intl/data/piweatherrock.lang.json b/piweatherrock/intl/data/piweatherrock.lang.json new file mode 100644 index 0000000..d8c59ae --- /dev/null +++ b/piweatherrock/intl/data/piweatherrock.lang.json @@ -0,0 +1,48 @@ +{ + "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": ""}, + "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": ""}, + "el":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "eo":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "et":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "fi":{"feels_like": "","wind": "","humidity": "","umbrella": "","no_umbrella": "","today": "","powered_by": "","tonight": "","tomorrow": "","check_at": "","sunrise": "","sunset": "","sunrise_at": "","sunset_at": "","daylight": ""}, + "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": ""}, + "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": ""}, + "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/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/piweatherrock-config.json b/piweatherrock/piweatherrock-config.json new file mode 100644 index 0000000..a3e1b0b --- /dev/null +++ b/piweatherrock/piweatherrock-config.json @@ -0,0 +1,39 @@ +{ + "12hour_disp": false, + "ds_api_key": "openmeteo-request-piweatherrock", + "fullscreen": true, + "icon_offset": -23.5, + "info_delay": 900, + "info_pause": 300, + "lang": "es", + "lat": 40.416775, + "log_level": "INFO", + "lon": -3.70379, + "plugins": { + "daily": { + "enabled": true, + "pause": 60 + }, + "hourly": { + "enabled": true, + "pause": 60 + }, + "info": { + "enabled": false, + "pause": 300 + }, + "media": { + "enabled": false, + "extensions": "jpg,jpeg,png,gif,bmp,mp4,mov,m4v,avi,webm", + "fit": "cover", + "path": "/home/pi/images", + "pause": 60, + "shuffle": true + } + }, + "timezone": "Europe/Madrid", + "ui_lang": "es", + "units": "si", + "update_freq": 300, + "version": "3.0.0" +} diff --git a/piweatherrock/plugin_info/__init__.py b/piweatherrock/plugin_info/__init__.py index 8148c29..c0459c9 100644 --- a/piweatherrock/plugin_info/__init__.py +++ b/piweatherrock/plugin_info/__init__.py @@ -7,6 +7,9 @@ import pygame import time +# local imports +from piweatherrock.intl import intl + class PluginInfo: """ @@ -30,7 +33,9 @@ def __init__(self, weather_rock): self.time_date_small_y_position = None self.sunrise_string = None self.sunset_string = None - + self.intl = None + self.ui_lang = None + self.get_rock_values(weather_rock) def get_rock_values(self, weather_rock): @@ -46,6 +51,10 @@ 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 + + #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,41 +112,41 @@ 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", {'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", {'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",{'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",{'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",{'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"]: - 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_media/__init__.py b/piweatherrock/plugin_media/__init__.py new file mode 100644 index 0000000..2b23e33 --- /dev/null +++ b/piweatherrock/plugin_media/__init__.py @@ -0,0 +1,352 @@ +# -*- coding: utf-8 -*- +"""Local media screen for images and short videos.""" + +import math +import os +import queue +import random +import subprocess +import threading +import time + +import pygame + +from piweatherrock.config_manager import ( + MEDIA_IMAGE_EXTENSIONS, + MEDIA_VIDEO_EXTENSIONS, + expand_config_path, +) + + +class PluginMedia: + """Displays images and short videos from a configured local folder.""" + + SCAN_INTERVAL = 30 + FONT_SIZE_RATIO = 0.06 + VIDEO_READ_TIMEOUT = 0.2 + + 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.video_reader = None + self.video_frames = queue.Queue(maxsize=2) + self.video_failed_path = 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_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._read_video_frame(path) + if frame is None: + return + if not frame: + self.log.info("Finished media video %s", path) + 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 = expand_config_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 = [] + 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 + 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 + self.video_failed_path = 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") + 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)) + + 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))), + 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 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," + "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: + process = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL) + except OSError: + self.log.warning("ffmpeg is not available for video playback") + return None + + frame_size = self.xmax * self.ymax * 3 + frames = queue.Queue(maxsize=2) + self.video_frames = frames + self.video_reader = threading.Thread( + target=self._read_video_frames, + args=(process, path, frame_size, frames)) + self.video_reader.daemon = True + self.video_reader.start() + return process + + def _stop_video(self): + process = self.video_process + if process is None: + return + + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=1) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + if self.video_reader is not None: + self.video_reader.join(timeout=1) + if process.stdout is not None: + process.stdout.close() + self.video_process = None + self.video_reader = None + self.video_frames = queue.Queue(maxsize=2) + + def _is_video_playing(self): + return self.video_process is not None + + def _read_video_frames(self, process, path, frame_size, frames): + try: + while True: + frame = self._read_exact_frame(process.stdout, frame_size) + if len(frame) < frame_size: + break + self._queue_video_frame(frames, frame) + except OSError: + self.log.exception("Could not read media video %s", path) + finally: + self._queue_video_frame(frames, b"") + + def _read_exact_frame(self, stream, frame_size): + if stream is None: + return b"" + frame = b"" + while len(frame) < frame_size: + chunk = stream.read(frame_size - len(frame)) + if not chunk: + break + frame += chunk + return frame + + def _queue_video_frame(self, frames, frame): + try: + frames.put_nowait(frame) + return + except queue.Full: + pass + + try: + frames.get_nowait() + except queue.Empty: + pass + try: + frames.put_nowait(frame) + except queue.Full: + pass + + def _read_video_frame(self, path): + process = self.video_process + try: + return self.video_frames.get(timeout=self.VIDEO_READ_TIMEOUT) + except queue.Empty: + if process.poll() is not None: + return b"" + return None + + def _render_message(self, message): + self.screen.fill((0, 0, 0)) + 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( + rendered, + ((self.xmax - width) / 2, (self.ymax - height) / 2)) + pygame.display.update() diff --git a/piweatherrock/plugin_weather_common/__init__.py b/piweatherrock/plugin_weather_common/__init__.py index fa2bb6e..22992c4 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 logging import pygame import time 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, @@ -219,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): @@ -269,12 +282,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 @@ -316,9 +329,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', @@ -437,40 +450,45 @@ 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. + of an icon in this project. If the resolved file does not exist, falls + back to unknown.png to prevent crashes. """ - if icon == 'clear-day': - icon_path = 'icons/{}/clear.png'.format(size) - elif icon == 'clear-night': - icon_path = 'icons/{}/nt_clear.png'.format(size) - elif icon == 'rain': - icon_path = 'icons/{}/rain.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': - icon_path = 'icons/{}/partlycloudy.png'.format(size) - elif icon == 'partly-cloudy-night': - icon_path = 'icons/{}/nt_partlycloudy.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_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/tstorm.png b/piweatherrock/plugin_weather_common/icons/256/tstorm.png new file mode 100644 index 0000000..cdd3111 Binary files /dev/null and b/piweatherrock/plugin_weather_common/icons/256/tstorm.png differ 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/tstorm.png b/piweatherrock/plugin_weather_common/icons/64/tstorm.png new file mode 100644 index 0000000..285f960 Binary files /dev/null and b/piweatherrock/plugin_weather_common/icons/64/tstorm.png differ 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/256/tstorm.png b/piweatherrock/plugin_weather_common/icons/alt_icons/256/tstorm.png new file mode 100644 index 0000000..cdd3111 Binary files /dev/null and b/piweatherrock/plugin_weather_common/icons/alt_icons/256/tstorm.png differ 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 0000000..285f960 Binary files /dev/null and b/piweatherrock/plugin_weather_common/icons/alt_icons/64/tstorm.png differ 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/plugin_weather_daily/__init__.py b/piweatherrock/plugin_weather_daily/__init__.py index 79a1df1..a5fefda 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").capitalize() 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/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/scripts/pwr-config-upgrade b/piweatherrock/pwr_config_upgrade.py similarity index 91% rename from scripts/pwr-config-upgrade rename to piweatherrock/pwr_config_upgrade.py index 9114be2..3176159 100644 --- a/scripts/pwr-config-upgrade +++ 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..1794ff0 --- /dev/null +++ b/piweatherrock/pwr_config_web.py @@ -0,0 +1,1452 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Local web configuration UI for PiWeatherRock.""" + +import html +import json +import os +import platform +import secrets +import shutil +import socket +import webbrowser +from argparse import ArgumentParser +from urllib.parse import urlencode +from urllib.request import urlopen + +import cherrypy + +from piweatherrock.config_manager import ( + CONFIG_FORM_FIELDS, + ConfigError, + MEDIA_FIT_MODES, + expand_config_path, + field_name, + get_config_value, + load_config, + SUPPORTED_LANGUAGES, + SUPPORTED_TIMEZONES, + set_config_value, + validate_config, + write_config_atomic, +) + +OPEN_METEO_FORECAST_URL = "https://api.open-meteo.com/v1/forecast" + + +TEXT = { + 'en': { + 'back_to_config': 'Back to configuration', + 'changes_applied': 'Changes applied', + 'config_error': 'Configuration error', + 'current_value_fallback': '{} (current value)', + 'error': 'Error', + 'invalid_request': 'Invalid request. Refresh the page and try again.', + 'legend': 'PiWeatherRock configuration', + 'location_custom': 'Custom coordinates', + 'location_preset': 'Quick location', + 'open_map': 'Open larger map', + 'map_hint': 'Move or zoom the map normally. Use the pin button only when you want to place the marker with a click.', + 'map_pick_active': 'Click the map to place the pin', + 'map_pick_start': 'Place pin on map', + 'map_title': 'Location map preview', + 'media_path_hint': 'Use an existing folder. Examples: /home/pi/Pictures, ~/Pictures, C:\\Users\\YourName\\Pictures, or your OneDrive Pictures folder. ~ and environment variables are expanded.', + 'plain_status': 'Plain status', + 'runtime_status': 'Runtime status', + '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.', + 'test_weather': 'Test Open-Meteo', + 'test_weather_ok': 'Open-Meteo responded successfully.', + 'test_weather_title': 'Open-Meteo test', + 'title': 'PiWeatherRock Config', + 'validate': 'Validate configuration', + 'validate_details': 'Review configuration', + 'validation_title': 'Configuration validation', + 'rotation_global_title': 'Global controls', + 'rotation_global_help': 'These settings control the automatic info cycle used across the rotation.', + 'rotation_pages_title': 'Per-page visibility', + 'rotation_pages_help': 'Each enabled page uses its own duration before moving to the next page.', + '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': 'Global info-cycle pause (seconds)', + 'info_delay': 'Global delay before automatic info page (seconds)', + 'daily_enabled': 'Daily page enabled', + 'daily_pause': 'Daily page visibility duration (seconds)', + 'hourly_enabled': 'Hourly page enabled', + 'hourly_pause': 'Hourly page visibility duration (seconds)', + 'info_enabled': 'Info page enabled', + 'info_pause_plugin': 'Info page visibility duration (seconds)', + 'media_enabled': 'Local media page enabled', + 'media_pause': 'Local media page visibility duration (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': 'Global controls are grouped first; each enabled page below has its own visibility duration.', + 'media': 'Local folder, ordering, fit mode, and allowed image/video extensions.', + 'diagnostics': 'Logging verbosity for troubleshooting.'}}, + 'es': { + 'back_to_config': 'Volver a la configuración', + 'changes_applied': 'Cambios aplicados', + 'config_error': 'Error de configuración', + 'current_value_fallback': '{} (valor actual)', + 'error': 'Error', + 'invalid_request': 'Solicitud no válida. Recarga la página e inténtalo de nuevo.', + 'legend': 'Configuración PiWeatherRock', + 'location_custom': 'Coordenadas personalizadas', + 'location_preset': 'Ubicación rápida', + 'open_map': 'Abrir mapa grande', + 'map_hint': 'Mueve o amplía el mapa normalmente. Usa el botón de chincheta solo cuando quieras colocar el marcador con un clic.', + 'map_pick_active': 'Haz clic en el mapa para colocar la chincheta', + 'map_pick_start': 'Colocar chincheta en el mapa', + 'map_title': 'Vista previa del mapa de ubicación', + 'media_path_hint': 'Usa una carpeta que exista. Ejemplos: /home/pi/Pictures, ~/Pictures, C:\\Users\\TuUsuario\\Pictures o tu carpeta Pictures de OneDrive. ~ y las variables de entorno se expanden.', + 'plain_status': 'Estado simple', + 'runtime_status': 'Estado de ejecució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.', + 'test_weather': 'Probar Open-Meteo', + 'test_weather_ok': 'Open-Meteo respondió correctamente.', + 'test_weather_title': 'Prueba de Open-Meteo', + 'title': 'Configuración de PiWeatherRock', + 'validate': 'Validar configuración', + 'validate_details': 'Revisar configuración', + 'validation_title': 'Validación de configuración', + 'rotation_global_title': 'Controles globales', + 'rotation_global_help': 'Estos ajustes controlan el ciclo automático de información usado en toda la rotación.', + 'rotation_pages_title': 'Visibilidad por página', + 'rotation_pages_help': 'Cada página activada usa su propia duración antes de pasar a la siguiente.', + '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 global del ciclo de información (segundos)', + 'info_delay': 'Retraso global antes de mostrar información automática (segundos)', + 'daily_enabled': 'Página diaria activada', + 'daily_pause': 'Duración visible de página diaria (segundos)', + 'hourly_enabled': 'Página horaria activada', + 'hourly_pause': 'Duración visible de página horaria (segundos)', + 'info_enabled': 'Página de información activada', + 'info_pause_plugin': 'Duración visible de página de información (segundos)', + 'media_enabled': 'Página de medios locales activada', + 'media_pause': 'Duración visible de página 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': 'Los controles globales se agrupan primero; cada página activada debajo tiene su propia duración visible.', + 'media': 'Carpeta local, orden, modo de ajuste y extensiones de imagen/vídeo permitidas.', + 'diagnostics': 'Nivel de detalle de los registros para solucionar problemas.'}}, + 'ca': { + 'back_to_config': 'Torna a la configuració', + 'changes_applied': 'Canvis aplicats', + 'config_error': 'Error de configuració', + 'current_value_fallback': '{} (valor actual)', + 'error': 'Error', + 'invalid_request': 'Sol·licitud no vàlida. Recarrega la pàgina i torna-ho a provar.', + 'legend': 'Configuració de PiWeatherRock', + 'location_custom': 'Coordenades personalitzades', + 'location_preset': 'Ubicació ràpida', + 'open_map': 'Obre el mapa gran', + 'map_hint': 'Mou o amplia el mapa normalment. Usa el botó de xinxeta només quan vulguis col·locar el marcador amb un clic.', + 'map_pick_active': 'Fes clic al mapa per col·locar la xinxeta', + 'map_pick_start': 'Col·loca xinxeta al mapa', + 'map_title': 'Vista prèvia del mapa d’ubicació', + 'media_path_hint': 'Usa una carpeta que existeixi. Exemples: /home/pi/Pictures, ~/Pictures, C:\\Users\\ElTeuUsuari\\Pictures o la carpeta Pictures de OneDrive. ~ i les variables d’entorn s’expandeixen.', + 'plain_status': 'Estat simple', + 'runtime_status': 'Estat d’execució', + '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.', + 'test_weather': 'Prova Open-Meteo', + 'test_weather_ok': 'Open-Meteo ha respost correctament.', + 'test_weather_title': 'Prova d’Open-Meteo', + 'title': 'Configuració de PiWeatherRock', + 'validate': 'Valida la configuració', + 'validate_details': 'Revisa la configuració', + 'validation_title': 'Validació de la configuració', + 'rotation_global_title': 'Controls globals', + 'rotation_global_help': 'Aquests ajustos controlen el cicle automàtic d’informació usat en tota la rotació.', + 'rotation_pages_title': 'Visibilitat per pàgina', + 'rotation_pages_help': 'Cada pàgina activada usa la seva pròpia durada abans de passar a la següent.', + '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 global del cicle d’informació (segons)', + 'info_delay': 'Retard global abans de mostrar informació automàtica (segons)', + 'daily_enabled': 'Pàgina diària activada', + 'daily_pause': 'Durada visible de pàgina diària (segons)', + 'hourly_enabled': 'Pàgina horària activada', + 'hourly_pause': 'Durada visible de pàgina horària (segons)', + 'info_enabled': 'Pàgina d’informació activada', + 'info_pause_plugin': 'Durada visible de pàgina d’informació (segons)', + 'media_enabled': 'Pàgina de mitjans locals activada', + 'media_pause': 'Durada visible de pàgina 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': 'Els controls globals s’agrupen primer; cada pàgina activada a sota té la seva pròpia durada visible.', + 'media': 'Carpeta local, ordre, mode d’ajust i extensions d’imatge/vídeo permeses.', + 'diagnostics': 'Nivell de detall dels registres per resoldre problemes.'}}, + 'gl': { + 'back_to_config': 'Volver á configuración', + 'changes_applied': 'Cambios aplicados', + 'config_error': 'Erro de configuración', + 'current_value_fallback': '{} (valor actual)', + 'error': 'Erro', + 'invalid_request': 'Solicitude non válida. Recarga a páxina e téntao de novo.', + 'legend': 'Configuración de PiWeatherRock', + 'location_custom': 'Coordenadas personalizadas', + 'location_preset': 'Localización rápida', + 'open_map': 'Abrir mapa grande', + 'map_hint': 'Move ou amplía o mapa normalmente. Usa o botón de chincheta só cando queiras colocar o marcador cun clic.', + 'map_pick_active': 'Fai clic no mapa para colocar a chincheta', + 'map_pick_start': 'Colocar chincheta no mapa', + 'map_title': 'Vista previa do mapa de localización', + 'media_path_hint': 'Usa un cartafol que exista. Exemplos: /home/pi/Pictures, ~/Pictures, C:\\Users\\OteuUsuario\\Pictures ou o teu cartafol Pictures de OneDrive. ~ e as variables de contorno expándense.', + 'plain_status': 'Estado simple', + 'runtime_status': 'Estado de execució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.', + 'test_weather': 'Probar Open-Meteo', + 'test_weather_ok': 'Open-Meteo respondeu correctamente.', + 'test_weather_title': 'Proba de Open-Meteo', + 'title': 'Configuración de PiWeatherRock', + 'validate': 'Validar configuración', + 'validate_details': 'Revisar configuración', + 'validation_title': 'Validación de configuración', + 'rotation_global_title': 'Controis globais', + 'rotation_global_help': 'Estes axustes controlan o ciclo automático de información usado en toda a rotación.', + 'rotation_pages_title': 'Visibilidade por páxina', + 'rotation_pages_help': 'Cada páxina activada usa a súa propia duración antes de pasar á seguinte.', + '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 global do ciclo de información (segundos)', + 'info_delay': 'Retardo global antes de mostrar información automática (segundos)', + 'daily_enabled': 'Páxina diaria activada', + 'daily_pause': 'Duración visible da páxina diaria (segundos)', + 'hourly_enabled': 'Páxina horaria activada', + 'hourly_pause': 'Duración visible da páxina horaria (segundos)', + 'info_enabled': 'Páxina de información activada', + 'info_pause_plugin': 'Duración visible da páxina de información (segundos)', + 'media_enabled': 'Páxina de medios locais activada', + 'media_pause': 'Duración visible da páxina 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': 'Os controis globais agrúpanse primeiro; cada páxina activada debaixo ten a súa propia duración visible.', + 'media': 'Cartafol local, orde, modo de axuste e extensións de imaxe/vídeo permitidas.', + 'diagnostics': 'Nivel de detalle dos rexistros para resolver problemas.'}}, + 'eu': { + 'back_to_config': 'Itzuli konfiguraziora', + 'changes_applied': 'Aldaketak aplikatu dira', + 'config_error': 'Konfigurazio-errorea', + 'current_value_fallback': '{} (uneko balioa)', + 'error': 'Errorea', + 'invalid_request': 'Eskaera ez da baliozkoa. Freskatu orria eta saiatu berriro.', + 'legend': 'PiWeatherRock konfigurazioa', + 'location_custom': 'Koordenatu pertsonalizatuak', + 'location_preset': 'Kokapen azkarra', + 'open_map': 'Ireki mapa handia', + 'map_hint': 'Mugitu edo handitu mapa normaltasunez. Erabili txintxeta botoia markatzailea klik batekin kokatu nahi duzunean bakarrik.', + 'map_pick_active': 'Egin klik mapan txintxeta kokatzeko', + 'map_pick_start': 'Kokatu txintxeta mapan', + 'map_title': 'Kokapen maparen aurrebista', + 'media_path_hint': 'Erabili existitzen den karpeta bat. Adibideak: /home/pi/Pictures, ~/Pictures, C:\\Users\\ZureErabiltzailea\\Pictures edo OneDriveko Pictures karpeta. ~ eta ingurune-aldagaiak hedatzen dira.', + 'plain_status': 'Egoera sinplea', + 'runtime_status': 'Exekuzio-egoera', + '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.', + 'test_weather': 'Probatu Open-Meteo', + 'test_weather_ok': 'Open-Meteok behar bezala erantzun du.', + 'test_weather_title': 'Open-Meteo proba', + 'title': 'PiWeatherRock konfigurazioa', + 'validate': 'Balidatu konfigurazioa', + 'validate_details': 'Berrikusi konfigurazioa', + 'validation_title': 'Konfigurazioaren balidazioa', + 'rotation_global_title': 'Kontrol globalak', + 'rotation_global_help': 'Ezarpen hauek biraketa osoan erabiltzen den informazio-ziklo automatikoa kontrolatzen dute.', + 'rotation_pages_title': 'Orri bakoitzeko ikusgaitasuna', + 'rotation_pages_help': 'Gaitutako orri bakoitzak bere iraupena erabiltzen du hurrengora pasatu aurretik.', + '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': 'Informazio-zikloaren pausa globala (segundoak)', + 'info_delay': 'Informazio automatikoa erakutsi aurreko atzerapen globala (segundoak)', + 'daily_enabled': 'Eguneko orria gaituta', + 'daily_pause': 'Eguneko orriaren ikusgai egoteko iraupena (segundoak)', + 'hourly_enabled': 'Orduko orria gaituta', + 'hourly_pause': 'Orduko orriaren ikusgai egoteko iraupena (segundoak)', + 'info_enabled': 'Informazio-orria gaituta', + 'info_pause_plugin': 'Informazio-orriaren ikusgai egoteko iraupena (segundoak)', + 'media_enabled': 'Tokiko multimedia-orria gaituta', + 'media_pause': 'Tokiko multimedia-orriaren ikusgai egoteko iraupena (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': 'Kontrol globalak lehenik taldekatzen dira; beheko gaitutako orri bakoitzak bere ikusgai egoteko iraupena du.', + 'media': 'Tokiko karpeta, ordena, doitze-modua eta baimendutako irudi/bideo luzapenak.', + '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'), + ('plugins', 'media', 'enabled'), ('plugins', 'media', 'pause'))), + ('media', ( + ('plugins', 'media', 'path'), ('plugins', 'media', 'shuffle'), + ('plugins', 'media', 'fit'), ('plugins', 'media', 'extensions'))), + ('diagnostics', (('log_level',),)), +] + +ROTATION_GLOBAL_FIELDS = (('info_pause',), ('info_delay',)) +ROTATION_PAGE_FIELDS = ( + ('plugins', 'daily', 'enabled'), ('plugins', 'daily', 'pause'), + ('plugins', 'hourly', 'enabled'), ('plugins', 'hourly', 'pause'), + ('plugins', 'info', 'enabled'), ('plugins', 'info', 'pause'), + ('plugins', 'media', 'enabled'), ('plugins', 'media', 'pause'), +) + +MAP_ZOOM_DELTA = 0.03 +COORDINATE_PRECISION = 6 +_TIMEZONE_OPTIONS = None + +SELECT_OPTIONS = { + ('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')], +} + +LOCATION_PRESETS = ( + # Common launch locations for supported languages plus a few global examples. + ('Getafe', 40.308250, -3.732393), + ('Madrid', 40.416775, -3.703790), + ('Barcelona', 41.387397, 2.168568), + ('Valencia', 39.469907, -0.376288), + ('Sevilla', 37.389092, -5.984459), + ('Bilbao', 43.263013, -2.934985), + ('A Coruña', 43.362344, -8.411540), + ('London', 51.507351, -0.127758), + ('Paris', 48.856613, 2.352222), + ('Lisboa', 38.722252, -9.139337), + ('New York', 40.712776, -74.005974), +) + + + +class ConfigWebApp: + def __init__(self, config_file): + self.config_file = config_file + self.csrf_token = secrets.token_urlsafe(32) + + @cherrypy.expose + def index(self, saved="", **_ignored): + self._set_no_store_headers() + try: + config = load_config(self.config_file) + message = self._text(config, "changes_applied") if saved == "1" else "" + body = self._render_form(config, message) + except ConfigError as exc: + body = self._page(TEXT["en"]["config_error"], + "

{}

".format(html.escape(str(exc)))) + return body + + @cherrypy.expose + def save(self, **params): + self._set_no_store_headers() + try: + config = load_config(self.config_file) + if not self._valid_save_request(config, params): + self._set_response_status(403) + return self._render_form( + config, "Error: {}".format( + self._text(config, "invalid_request"))) + 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) + validate_config(config) + write_config_atomic(self.config_file, config) + raise cherrypy.HTTPRedirect("/?saved=1") + 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(TEXT["en"]["error"], "

{}

".format( + html.escape(str(exc)))) + + @cherrypy.expose + def status(self): + self._set_no_store_headers() + try: + config = load_config(self.config_file) + return self._text(config, "status_ok") + except ConfigError as exc: + self._set_response_status(400) + return "ERROR: {}".format(exc) + + @cherrypy.expose + def validate(self): + self._set_no_store_headers() + try: + config = load_config(self.config_file) + body = self._render_validation(config) + language = self._language(config) + except ConfigError as exc: + language = "en" + body = self._render_status_items( + [(False, TEXT["en"]["config_error"], str(exc))], language) + body += self._back_link(language) + return self._page(self._text_for_language(language, "validation_title"), + body, language) + + @cherrypy.expose + def test_weather(self): + self._set_no_store_headers() + try: + config = load_config(self.config_file) + language = self._language(config) + body = self._render_weather_test(config, language) + except ConfigError as exc: + language = "en" + body = self._render_status_items( + [(False, TEXT["en"]["config_error"], str(exc))], language) + body += self._back_link(language) + return self._page(self._text_for_language(language, "test_weather_title"), + body, language) + + def _render_form(self, config, message=""): + rows = [] + if message: + rows.append('

{}

'.format(html.escape(message))) + rows.append('
') + rows.append(''.format( + html.escape(self.csrf_token, quote=True))) + 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)))) + 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, "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) + 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(config, path, value) + elif path == ('timezone',): + control = self._render_select(config, 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) + hint = self._field_hint(config, path) + hint_html = ( + '

{}

'.format(html.escape(hint)) + if hint else "") + return ('
' + '{control}{hint}
').format( + name=escaped_name, label=label, control=control, + hint=hint_html) + + def _render_select(self, config, path, value): + name = field_name(path) + options = list(self._select_options(path)) + option_values = [option_value for option_value, _ in options] + if value not in option_values: + options.insert(0, (value, self._text( + config, "current_value_fallback").format(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_controls(self, config): + lat = float(get_config_value(config, ("lat",))) + lon = float(get_config_value(config, ("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")), + 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)) + + 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): + options = [] + for label, lat, lon in LOCATION_PRESETS: + selected = " selected" if self._same_coordinate_pair( + current_lat, current_lon, lat, lon) else "" + options.append( + ''.format( + lat=html.escape(str(lat), quote=True), + lon=html.escape(str(lon), quote=True), + selected=selected, + label=html.escape(label))) + return "\n ".join(options) + + def _same_coordinate_pair(self, first_lat, first_lon, second_lat, second_lon): + first = ( + round(first_lat, COORDINATE_PRECISION), + round(first_lon, COORDINATE_PRECISION), + ) + second = ( + round(second_lat, COORDINATE_PRECISION), + round(second_lon, COORDINATE_PRECISION), + ) + return first == second + + def _page(self, title, body, language="en"): + return """ + + + + + {title} + + + +
+
+
+

{title}

+

{subtitle}

+
+ +
+ {body} +
+ + +""".format( + language=html.escape(language), + title=html.escape(title), + subtitle=html.escape(TEXT.get(language, TEXT["en"])["subtitle"]), + map_delta=MAP_ZOOM_DELTA, + 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 = MAP_ZOOM_DELTA + 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" + 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 _field_definition(self, path): + for field_path, label_key, field_type in CONFIG_FORM_FIELDS: + if field_path == path: + return label_key, field_type + raise KeyError(path) + + def _select_options(self, path): + if path == ('timezone',): + return self._timezone_options() + return SELECT_OPTIONS[path] + + def _timezone_options(self): + global _TIMEZONE_OPTIONS + if _TIMEZONE_OPTIONS is None: + _TIMEZONE_OPTIONS = tuple( + (timezone, timezone) for timezone in SUPPORTED_TIMEZONES) + return _TIMEZONE_OPTIONS + + 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] + if key in TEXT["en"]: + return TEXT["en"][key] + raise KeyError("Missing web configuration text for key: {}".format(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 SUPPORTED_LANGUAGES: + language = language.split("_")[0].split("-")[0] + if language not in TEXT: + language = "en" + return language + + def _set_no_store_headers(self): + """Force fresh HTML so local users do not keep the old plain form cached.""" + 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(): + 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') + 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, + }) + 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/scripts/pwr-ui b/piweatherrock/pwr_ui.py old mode 100755 new mode 100644 similarity index 100% rename from scripts/pwr-ui rename to piweatherrock/pwr_ui.py diff --git a/piweatherrock/runner.py b/piweatherrock/runner.py index 508a50f..1bddc16 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 @@ -12,16 +11,36 @@ # 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 ( + 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 from piweatherrock.plugin_info import PluginInfo +from piweatherrock.plugin_media import PluginMedia + + +UI_LOOP_FREQUENCY = 10 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", + 'i': "info", + 'm': "media", + } def __init__(self): self.current_screen = None @@ -36,11 +55,15 @@ 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): - 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 self.my_weather_rock = Weather(config_file) @@ -48,12 +71,10 @@ 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.current_screen = 'd' - - self.d_count = 1 - self.h_count = 0 + self.switch_to_default_weather_screen() # Stay running while True self.running = True @@ -67,10 +88,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 ################################################################## @@ -79,6 +100,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. @@ -107,27 +129,19 @@ 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: - 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: @@ -138,47 +152,22 @@ def screen_switcher(self): This function takes care of cycling through the different screens on a regular basis. """ - - # 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 - if self.non_weather_timeout > (self.config["info_pause"] * 10): - self.current_screen = 'd' - self.d_count = 1 - 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"] * 10): - 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: - 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.ensure_current_screen_enabled() + 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': # 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 @@ -191,7 +180,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 @@ -207,7 +200,20 @@ 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") + + # 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: @@ -219,3 +225,144 @@ 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: + # 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}") + 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 + + 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_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 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 not in self.enabled_screens(): + self.switch_to_default_weather_screen() + + def switch_to_default_weather_screen(self): + enabled = self.enabled_screens() + if not enabled: + self.current_screen = 'i' + self.d_count = 0 + self.h_count = 0 + else: + self.switch_to_screen(enabled[0]) + + def switch_to_weather_screen(self, screen): + if screen not in self.enabled_weather_screens(): + self.my_weather_rock.log.warning( + 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.switch_to_next_screen() + return + + if self.current_screen == 'd' and 'h' in enabled: + self.advance_weather_screen('h', "Switching to HOURLY") + elif self.current_screen == 'h' and 'd' in enabled: + self.advance_weather_screen('d', "Switching to DAILY") + elif enabled[0] == 'd': + self.advance_weather_screen('d', "Staying on DAILY") + 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.switch_to_screen(screen) + if screen == 'd': + self.d_count += 1 + else: + self.h_count += 1 + + def screen_pause(self, 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_name = self.SCREEN_TO_PLUGIN_NAME.get(screen) + if not plugin_name: + return None + return getattr(self, plugin_name) diff --git a/piweatherrock/weather.py b/piweatherrock/weather.py index 587289c..6007664 100644 --- a/piweatherrock/weather.py +++ b/piweatherrock/weather.py @@ -10,15 +10,25 @@ import signal import sys import time -import json import logging import logging.handlers # third party imports -from darksky import forecast +from piweatherrock.climate import forecast import pygame 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 + # globals UNICODE_DEGREE = u'\xb0' @@ -32,48 +42,24 @@ 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): - with open(config_file, "r") as f: - self.config = json.load(f) + self.config = load_config(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() - 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.exception("No suitable video driver found!") + self._init_display() size = (pygame.display.Info().current_w, pygame.display.Info().current_h) @@ -93,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." @@ -125,9 +155,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') @@ -146,7 +176,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"], @@ -154,32 +183,13 @@ def get_forecast(self): self.config["lon"], 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' - else: - index = 1 - sr_suffix = 'tomorrow' - ss_suffix = '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) + lang=self.config["lang"], + timezone=self.config["timezone"]) + + self.update_sun_strings() + + # 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}") @@ -189,6 +199,63 @@ 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): + """ + Compute sunrise and sunset strings from current weather and display settings. + """ + daily = getattr(self.weather, "daily", None) + if daily is None: + return + + sunset_today = datetime.datetime.fromtimestamp( + 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 = daily[index].sunriseTime + self.sunset = 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 new file mode 100644 index 0000000..201f397 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,46 @@ +[build-system] +requires = ["setuptools>=68.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "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", +] +dependencies = [ + "pygame", + "pyserial", + "requests", + "cherrypy", + "babel", + "python-i18n", + "pytz", +] + +[project.urls] +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] +include = ["piweatherrock*"] + +[tool.setuptools.package-data] +piweatherrock = [ + "config.json-sample", + "data/*.json", + "intl/data/*.json", + "plugin_weather_common/icons/**/*", +] diff --git a/requirements.txt b/requirements.txt index cf64753..ff44a6c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ -darkskylib pygame pyserial requests cherrypy - -piweatherrock-webconfig==1.5.0 +babel +python-i18n +pytz diff --git a/setup.py b/setup.py deleted file mode 100644 index c1b591c..0000000 --- a/setup.py +++ /dev/null @@ -1,43 +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, - 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/tests/test_config_manager.py b/tests/test_config_manager.py new file mode 100644 index 0000000..b24a264 --- /dev/null +++ b/tests/test_config_manager.py @@ -0,0 +1,190 @@ +import json +import os +import tempfile +import unittest + +from piweatherrock.config_manager import ( + ConfigError, + ConfigWatcher, + diff_config, + load_config, + merge_defaults, + normalize_config, + validate_config, + 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}, + "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 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_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") + 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_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_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") + 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() diff --git a/tests/test_config_web.py b/tests/test_config_web.py new file mode 100644 index 0000000..85d7e38 --- /dev/null +++ b/tests/test_config_web.py @@ -0,0 +1,166 @@ +import importlib +from pathlib import Path +import types +import unittest +from unittest import mock + + +def _config_web_module(): + try: + import cherrypy # noqa: F401 + except ImportError: + cherrypy = types.ModuleType("cherrypy") + cherrypy.expose = lambda function: function + with mock.patch.dict("sys.modules", {"cherrypy": cherrypy}): + module = importlib.import_module("piweatherrock.pwr_config_web") + else: + module = importlib.import_module("piweatherrock.pwr_config_web") + return module + + +ConfigWebModule = _config_web_module() +ConfigWebApp = ConfigWebModule.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_current_entrypoint_in_pyproject(self): + pyproject = Path(__file__).resolve().parents[1] / "pyproject.toml" + text = pyproject.read_text() + + self.assertIn('pwr-config-web = "piweatherrock.pwr_config_web:main"', text) + self.assertNotIn("pwr-webconfig", text) + self.assertNotIn("piweatherrock-webconfig", text) + + def test_form_groups_fields_with_help_and_map(self): + html = ConfigWebApp("config.json")._render_form(VALID_CONFIG) + + self.assertIn('
', html) + 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('', html) + self.assertIn('', html) + + def test_pause_labels_group_global_and_per_page_pauses(self): + html = ConfigWebApp("config.json")._render_form(VALID_CONFIG) + english_config = dict(VALID_CONFIG, ui_lang="en") + english_html = ConfigWebApp("config.json")._render_form(english_config) + + self.assertIn('Controles globales', html) + self.assertIn('Visibilidad por página', html) + self.assertIn('Duración visible de página diaria', html) + self.assertIn('Duración visible de página de medios locales', html) + self.assertIn('Global controls', english_html) + self.assertIn('Per-page visibility', english_html) + self.assertLess( + html.index('Pausa global del ciclo de información'), + html.index('Duración visible de página diaria')) + + def test_page_includes_theme_toggle(self): + html = ConfigWebApp("config.json")._render_form(VALID_CONFIG) + + self.assertIn('id="theme-toggle"', html) + self.assertIn('data-theme', html) + self.assertIn('localStorage', html) + + def test_location_section_includes_preset_selector(self): + html = ConfigWebApp("config.json")._render_form(VALID_CONFIG) + + self.assertIn('id="location-preset"', html) + self.assertIn('', html) + self.assertIn('', html) + self.assertIn("preset.addEventListener('change'", html) + self.assertIn("pickToggle.addEventListener('click'", html) + + def test_media_section_rendered(self): + html = ConfigWebApp("config.json")._render_form(VALID_CONFIG) + + self.assertIn('aria-labelledby="media-title"', html) + self.assertIn('plugins__media__enabled', html) + self.assertIn('plugins__media__path', html) + self.assertIn('plugins__media__fit', html) + self.assertIn('class="field-grid media-grid"', html) + self.assertIn('Usa una carpeta que exista', html) + self.assertIn('variables de entorno se expanden', html) + + def test_help_icon_uses_large_custom_tooltip(self): + html = ConfigWebApp("config.json")._render_form(VALID_CONFIG) + + self.assertIn('data-tooltip=', html) + self.assertIn('', html) + self.assertNotIn('☝', html) + + def test_form_links_to_validation_and_weather_test(self): + html = ConfigWebApp("config.json")._render_form(VALID_CONFIG) + + self.assertIn('href="/validate"', html) + self.assertIn('href="/test_weather"', html) + self.assertIn('Probar Open-Meteo', html) + + def test_form_uses_csrf_token_and_fixed_saved_message(self): + app = ConfigWebApp("config.json") + html = app._render_form(VALID_CONFIG) + + self.assertIn('name="csrf_token"', html) + request = types.SimpleNamespace(method="POST") + with mock.patch.object( + ConfigWebModule.cherrypy, "request", request, create=True): + self.assertTrue(app._valid_save_request( + VALID_CONFIG, {"csrf_token": app.csrf_token})) + self.assertFalse(app._valid_save_request( + VALID_CONFIG, {"csrf_token": "wrong"})) + request.method = "GET" + self.assertFalse(app._valid_save_request( + VALID_CONFIG, {"csrf_token": app.csrf_token})) + self.assertNotIn("message=", html) + + +if __name__ == "__main__": + unittest.main() 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() diff --git a/tests/test_plugin_media.py b/tests/test_plugin_media.py new file mode 100644 index 0000000..caef1ac --- /dev/null +++ b/tests/test_plugin_media.py @@ -0,0 +1,61 @@ +import queue +import types +import unittest + +try: + from piweatherrock.plugin_media import PluginMedia +except ModuleNotFoundError as exc: + if exc.name != "pygame": + raise + PluginMedia = None + + +class DummyProcess: + def __init__(self, returncode=None): + self.returncode = returncode + + def poll(self): + return self.returncode + + +@unittest.skipIf(PluginMedia is None, "pygame is not installed") +class PluginMediaVideoReadTest(unittest.TestCase): + def setUp(self): + weather_rock = types.SimpleNamespace( + config={ + "plugins": { + "media": { + "path": "", + "shuffle": False, + "fit": "contain", + "extensions": "", + } + } + }, + screen=None, + log=types.SimpleNamespace( + warning=lambda *args, **kwargs: None, + exception=lambda *args, **kwargs: None, + info=lambda *args, **kwargs: None, + ), + xmax=2, + ymax=2, + ) + self.plugin = PluginMedia(weather_rock) + + def test_read_video_frame_uses_queued_reader_frame(self): + self.plugin.video_process = DummyProcess() + self.plugin.video_frames.put(b"frame") + + self.assertEqual(self.plugin._read_video_frame("clip.mp4"), b"frame") + + def test_read_video_frame_reports_eof_after_process_exits(self): + self.plugin.video_process = DummyProcess(returncode=0) + self.plugin.video_frames = queue.Queue() + self.plugin.VIDEO_READ_TIMEOUT = 0.01 + + self.assertEqual(self.plugin._read_video_frame("clip.mp4"), b"") + + +if __name__ == "__main__": + unittest.main() diff --git a/version.py b/version.py deleted file mode 100644 index a33997d..0000000 --- a/version.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = '2.1.0'