diff --git a/8x32 PI/CODER_EN_PYTHON.md b/8x32 PI/CODER_EN_PYTHON.md index 56bd7de..983fc82 100644 --- a/8x32 PI/CODER_EN_PYTHON.md +++ b/8x32 PI/CODER_EN_PYTHON.md @@ -100,20 +100,23 @@ grâce à la ligne `from matrice import *`. | Outil | Effet | |---|---| -| `pixel(x, y, couleur)` | colore le pixel de la colonne `x` (0 à 31) et de la ligne `y` (0 à 7). En dehors : ignoré | +| `pixel(x, y, couleur)` | colore le pixel de la colonne `x` (0 à 7) et de la ligne `y` (0 à 31), matrice debout. En dehors : ignoré | | `lire_pixel(x, y)` | renvoie la couleur actuelle d'un pixel | | `effacer()` | éteint toute la toile | | `effacer(couleur)` | remplit toute la toile d'une couleur | | `assombrir(25)` | assombrit toute la toile de 25 % (pour les traînées) | | `montrer()` | **affiche** la toile. Rien n'apparaît tant qu'on ne l'appelle pas ! | -| `montrer(image)` | affiche une image Pillow de 32 × 8 (voir partie 5) | +| `montrer(image)` | affiche une image Pillow de 8 × 32 (voir partie 5) | | `attendre(100)` | attend 100 millisecondes | | `teinte(t)` | couleur de l'arc-en-ciel : 0 rouge, 32 orange, 64 jaune, 96 vert, 128 cyan, 160 bleu, 192 violet, 224 rose | | `teinte(t, saturation, valeur)` | idem, avec saturation (0 = blanc) et valeur (0 = noir), de 0 à 255 | **Couleurs nommées** : `NOIR`, `BLANC`, `ROUGE`, `ORANGE`, `JAUNE`, `VERT`, `CYAN`, `BLEU`, `VIOLET`, `MAGENTA`, `ROSE`. -**Dimensions** : `LARGEUR` (32), `HAUTEUR` (8). +**Dimensions** : `LARGEUR` (8) et `HAUTEUR` (32) quand la matrice est +debout (`ROTATION = 90` dans `matrice.py`) ; 32 et 8 quand elle est couchée. +Écrivez vos animations avec `LARGEUR` et `HAUTEUR` plutôt qu'avec des +nombres : elles marcheront dans les deux sens. Le point `(0, 0)` est **en haut à gauche**, comme dans Krita. @@ -181,19 +184,19 @@ utilise Pillow, la bibliothèque d'images : from PIL import Image def anim_logo_qui_defile(): - # Remplacez par le chemin d'une image de 32 × 8 exportée depuis Krita. + # Remplacez par le chemin d'une image de 8 × 32 exportée depuis Krita. logo = Image.open("animations/logo.png").convert("RGB") for i in range(100): effacer() - # On « colle » le logo deux fois, côte à côte, décalé de i pixels : - # ce qui sort à droite rentre à gauche (défilement en boucle). - toile.paste(logo, (i % LARGEUR - LARGEUR, 0)) - toile.paste(logo, (i % LARGEUR, 0)) + # On « colle » le logo deux fois, l'un au-dessus de l'autre, décalé + # de i pixels : ce qui sort en bas rentre en haut (défilement en boucle). + toile.paste(logo, (0, i % HAUTEUR - HAUTEUR)) + toile.paste(logo, (0, i % HAUTEUR)) montrer() attendre(60) ``` -Ici `toile` est l'image de 32 × 8 sur laquelle dessinent `pixel()` et +Ici `toile` est l'image de 8 × 32 sur laquelle dessinent `pixel()` et `effacer()`. Tout ce que sait faire Pillow (coller, retourner, changer les couleurs, écrire du texte…) fonctionne dessus. Documentation : . diff --git a/8x32 PI/GUIDE_KRITA.md b/8x32 PI/GUIDE_KRITA.md index ea43de4..9a5ab52 100644 --- a/8x32 PI/GUIDE_KRITA.md +++ b/8x32 PI/GUIDE_KRITA.md @@ -6,7 +6,8 @@ le fichier dans le dossier des animations. ![De Krita à la matrice](schemas/chaine_krita_matrice.svg) -> **En une phrase** : un document de **32 × 8 pixels**, un pixel = une LED, +> **En une phrase** : un document **vertical** de **8 × 32 pixels** (8 de +> large, 32 de haut), un pixel = une LED, > des couleurs **franches**, un fond **noir**, et un export en **séquence de > PNG** (ou en GIF). @@ -29,26 +30,47 @@ le fichier dans le dossier des animations. ## 1. Comprendre la « toile » -La matrice est un écran de **32 colonnes × 8 lignes** = 256 pixels. Chaque +La matrice est posée **debout** dans la vitrine : c'est un écran de +**8 colonnes × 32 lignes** = 256 pixels, comme une enseigne verticale. Chaque pixel est une LED, et les LED sont espacées de **1 cm** : la matrice mesure -32 × 8 cm. +8 cm de large et 32 cm de haut. ``` -x → 0 31 -y ↓ ┌────────────────────────────────┐ - 0 │■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ │ - │ │ 8 lignes - 7 │■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ │ - └────────────────────────────────┘ - 32 colonnes + x → 0 . . . . . . 7 + y ↓ ┌─────────────────┐ + 0 │ ■ ■ ■ ■ ■ ■ ■ ■ │ + │ ■ ■ ■ ■ ■ ■ ■ ■ │ + │ … │ 32 lignes + │ ■ ■ ■ ■ ■ ■ ■ ■ │ + 31 │ ■ ■ ■ ■ ■ ■ ■ ■ │ + └─────────────────┘ + 8 colonnes ``` Quelques ordres de grandeur pour se repérer : -- un caractère lisible fait **5 pixels de large × 7 de haut** : on écrit - **5 lettres** à la fois, pas plus. Un texte plus long doit **défiler** ; -- un visage simple tient dans **8 × 8** ; -- une ligne de 1 pixel d'épaisseur se voit très bien : c'est une rangée de LED. +- un caractère lisible fait **5 pixels de large × 7 de haut**. Deux façons + d'écrire : + - lettres **empilées**, les unes sous les autres, comme une enseigne + d'hôtel : **4 lettres** visibles à la fois (4 × 8 = 32) ; + - texte **couché**, qui se lit en penchant la tête, comme le titre au dos + d'un livre : **5 lettres** à la fois. + + Un texte plus long doit **défiler** (vers le haut) ; +- un visage simple tient dans **8 × 8** : il y a la place pour 4 visages + l'un au-dessus de l'autre ; +- une ligne de 1 pixel d'épaisseur se voit très bien : c'est une rangée de LED ; +- **jouez avec la verticalité** : ce qui monte, tombe, coule, grimpe, se remplit + (une pluie, une bulle, un thermomètre, une fusée, une liane…). + +> **Dessiné couché par erreur ?** Une image de 32 × 8 (couchée) est quand même +> jouée : le lecteur la fait tourner d'un quart de tour, et elle se lit alors +> de bas en haut. Pratique pour un texte « dos de livre », mais pensez en +> vertical dès le départ, c'est plus beau. + +> **La matrice finale fera 39 × 7** (ou 7 × 39 si elle est aussi verticale) : +> on vous le précisera. Le lecteur centre les images qui n'ont pas la bonne +> taille, et coupe ce qui dépasse. > **La matrice finale fera 39 × 7.** Si vous préparez déjà des animations > pour elle, créez des documents de **39 × 7** : sur la matrice d'essai, le @@ -63,8 +85,8 @@ Quelques ordres de grandeur pour se repérer : | Réglage | Valeur | |---|---| -| Largeur | **32** pixels | -| Hauteur | **8** pixels | +| Largeur | **8** pixels | +| Hauteur | **32** pixels | | Résolution | peu importe (72 ppp) | | Modèle de couleur | **RVB/Alpha**, **8 bits** entier, profil **sRGB** (celui par défaut) | | Contenu / arrière-plan | **noir** (ou transparent : le transparent devient noir, donc éteint) | @@ -75,8 +97,8 @@ Quelques ordres de grandeur pour se repérer : beaucoup. Si elle n'apparaît pas : **Configuration → Configurer Krita → Affichage → Grille de pixels**, et baissez le seuil d'affichage. -> ⚠️ **Ne dessinez pas en grand pour réduire ensuite.** Une image de 1920 × -> 480 réduite à 32 × 8 devient une bouillie floue. Dessinez **directement à +> ⚠️ **Ne dessinez pas en grand pour réduire ensuite.** Une image de 480 × +> 1920 réduite à 8 × 32 devient une bouillie floue. Dessinez **directement à > la taille réelle** : c'est le principe du pixel art. ### Importer la palette « Vitrine pixels » @@ -196,7 +218,7 @@ supplémentaire. 2. **Dossier** : créez un dossier **au nom de l'animation**, par exemple `05_feu_de_camp`. 3. **Nom de base** : `feu` → Krita crée `feu_0000.png`, `feu_0001.png`… -4. Taille : laissez **32 × 8**. +4. Taille : laissez **8 × 32**. 5. **Rendu**. 6. Dans ce dossier, créez un fichier texte **`images_par_seconde.txt`** qui contient seulement le nombre d'images par seconde de votre animation, @@ -220,7 +242,7 @@ Un seul fichier, pratique à partager, et la vitesse est enregistrée dedans. 2. Krita a besoin du logiciel gratuit **FFmpeg** pour ça. S'il le demande : téléchargez-le () et indiquez son emplacement dans la fenêtre de rendu. -3. Taille **32 × 8**. Nommez le fichier, par exemple `05_feu_de_camp.gif`. +3. Taille **8 × 32**. Nommez le fichier, par exemple `05_feu_de_camp.gif`. > **Limites du GIF** : 256 couleurs maximum (largement assez ici), et une > durée d'image arrondie au centième de seconde. Le **PNG animé (APNG)** et le @@ -264,7 +286,7 @@ Il affiche un rapport : ``` 05_feu_de_camp Type : séquence de 24 fichiers (12 images/s) - Taille : 32 × 8 ✓ + Taille : 8 × 32 ✓ Images : 24, durée 2.00 s (≈ 12.0 images/s) Couleurs : 9 différentes ✓ Trop sombre : aucune ✓ @@ -278,7 +300,7 @@ l'équipe. | Message | Signification | |---|---| -| `⚠ attendu 32 × 8` | mauvaise taille de document : l'image sera centrée et coupée | +| `⚠ attendu 8 × 32` | mauvaise taille de document : l'image sera centrée et coupée (ou tournée si elle est couchée, 32 × 8) | | `⚠ beaucoup ! anticrénelage ?` | plus de 64 couleurs : du flou s'est glissé dans le dessin | | `Trop sombre : N couleur(s) seront ÉTEINTES` | des couleurs trop foncées (partie 5) | | `⚠ limité par le logiciel` | image trop claire et trop remplie : elle sera assombrie | @@ -310,6 +332,11 @@ numéro : `01_`, `02_`, `03_`… Selon l'organisation de l'équipe : +- **Par la page web (le plus simple)** : ouvrez `http://172.16.50.27` (depuis le Wi-Fi) dans un + navigateur, sur le même réseau que le Pi, et déposez vos fichiers. On y + choisit aussi l'ordre et les animations jouées. Voir + [INTERFACE_WEB.md](INTERFACE_WEB.md). Les noms y sont nettoyés et numérotés + automatiquement. - **Carte SD** : on éteint le Pi, on sort la carte, on la met dans l'ordinateur, on copie les animations dans le dossier `animations` du lecteur nommé **`bootfs`**, on remet la carte. Voir @@ -329,8 +356,8 @@ dans un sous-dossier dont le nom commence par un point, par exemple (couleur vive sur noir) se lit de loin. - **Le mouvement attire l'œil** : une animation lente et régulière se regarde longtemps ; une image fixe passe inaperçue. -- **Texte** : 5 lettres visibles à la fois. Un défilement d'**1 pixel toutes - les 50 à 80 ms** se lit confortablement. Plus vite, on ne lit plus. +- **Texte** : 4 lettres empilées (ou 5 couchées) visibles à la fois. Un + défilement d'**1 pixel toutes les 50 à 80 ms** se lit confortablement. Plus vite, on ne lit plus. - **Boucles** : soignez la jonction fin → début. Un saut se remarque beaucoup plus sur 256 pixels que sur un écran. - **Diffuseur** : si la matrice est placée derrière un matériau diffusant @@ -356,7 +383,7 @@ crise. On s'en passe. ## 10. Liste de contrôle avant de rendre une animation -- [ ] Document en **32 × 8** pixels (ou 39 × 7 pour la matrice finale) +- [ ] Document **vertical** en **8 × 32** pixels - [ ] Pas de flou : **moins de 64 couleurs** (vérificateur) - [ ] Pas de couleurs **trop sombres** (vérificateur) - [ ] Fond **noir** ou transparent diff --git a/8x32 PI/INTERFACE_WEB.md b/8x32 PI/INTERFACE_WEB.md new file mode 100644 index 0000000..f70afa4 --- /dev/null +++ b/8x32 PI/INTERFACE_WEB.md @@ -0,0 +1,212 @@ +# L'interface web : gérer la vitrine depuis un navigateur + +Le Raspberry Pi héberge une petite **page web**. Depuis n'importe quel +ordinateur ou téléphone **connecté au même réseau que le Pi**, on peut : + +- **déposer** des animations (GIF, PNG animé, WebP, image fixe, séquence de PNG) ; +- voir un **aperçu animé** de chacune, tel que la matrice l'affichera ; +- choisir **lesquelles sont jouées** et **dans quel ordre** ; +- en jouer une **tout de suite**, ou **passer à la suivante** ; +- les **supprimer** (elles passent d'abord par une corbeille). + +C'est la façon la plus simple, pour l'équipe design, de mettre ses animations +en vitrine : pas de terminal, pas de carte SD à sortir. + +--- + +## Sommaire + +1. [Ouvrir la page](#1-ouvrir-la-page) +2. [Ajouter des animations](#2-ajouter-des-animations) +3. [Choisir ce qui est joué](#3-choisir-ce-qui-est-joué) +4. [Ce qu'il faut savoir](#4-ce-quil-faut-savoir) +5. [Comment ça marche (pour les curieux)](#5-comment-ça-marche-pour-les-curieux) +6. [Réglages et dépannage](#6-réglages-et-dépannage) + +--- + +## 1. Ouvrir la page + +Dans le navigateur (Firefox, Chrome, Safari…), tapez l'**adresse du Pi**. +Le Pi de l'école est branché sur **deux réseaux**, et a donc **deux adresses**. +Prenez celle du réseau sur lequel **vous** êtes : + +| Vous êtes connecté… | Adresse à taper | +|---|---| +| en **Wi-Fi** (téléphone, ordinateur portable) | **`http://172.16.50.27`** | +| par **câble** (ordinateur fixe de l'école) | `http://172.16.7.199` | + +> **Pourquoi deux adresses ?** Une adresse, c'est comme l'adresse postale du +> Pi, mais **sur un réseau donné**. Le réseau Wi-Fi et le réseau filaire de +> l'école sont deux « villes » différentes, et l'école ne fait pas passer le +> courrier de l'une à l'autre. Depuis le Wi-Fi, l'adresse filaire du Pi ne +> mène nulle part : la page semble « inaccessible ». + +> ⚠️ **Ces adresses peuvent changer** (au redémarrage du Pi, ou au bout de +> quelques jours) : c'est la box ou le réseau de l'école qui les distribue. +> Les adresses actuelles sont toujours écrites **en bas de la page web**, et +> la commande `hostname -I` sur le Pi les affiche. Pour une vitrine, demandez +> au service informatique de **réserver** une adresse fixe pour le Pi. + +> **La page ne s'ouvre toujours pas ?** Votre appareil doit être sur le +> **même réseau** que l'adresse utilisée. Les données mobiles (4G/5G) ne +> marchent pas : la page n'est pas sur Internet, seulement sur le réseau local. + +En haut de la page, en permanence : **ce que la matrice joue en ce moment** +(mis à jour toutes les 2 secondes) et le bouton **Suivante ⏭**. + +--- + +## 2. Ajouter des animations + +### Des fichiers (GIF, PNG animé, WebP, image fixe) + +1. Cadre **« Un ou plusieurs fichiers »** → **Choisir des fichiers…** +2. Sélectionnez un ou plusieurs exports Krita (voir + [GUIDE_KRITA.md](GUIDE_KRITA.md)). Sur téléphone, on peut les prendre dans + la galerie ou dans « Fichiers ». +3. **Envoyer**. + +Chaque fichier devient une animation, ajoutée **à la fin de la liste**. Les +noms sont nettoyés automatiquement : `Pluie de cœurs (v2)!.gif` devient +`pluie_de_coeurs_v2.gif`. + +### Une séquence de PNG + +Pour un export Krita en **séquence d'images** (`feu_0000.png`, +`feu_0001.png`…) : + +1. Cadre **« Une séquence de PNG »** → **Choisir les PNG…** → sélectionnez + **toutes** les images de la séquence (Ctrl + A dans le dossier). +2. Donnez un **nom** à l'animation et sa vitesse en **images par seconde** + (celle réglée dans Krita). +3. **Envoyer**. + +Les images sont mises dans l'ordre de leurs noms : gardez la numérotation de +Krita. + +### Vérifications automatiques + +Chaque envoi est **ouvert et vérifié** avant d'être accepté, comme le ferait +le lecteur. Un fichier abîmé ou d'un format inconnu est **refusé**, avec un +message en bas de l'écran. + +Si une image n'a pas la bonne taille (8 × 32, debout), elle est quand même +acceptée, mais sa fiche affiche un avertissement, par exemple **⚠ 32 × 8 px +au lieu de 8 × 32** (image couchée, qui sera tournée d'un quart de tour) : +regardez l'aperçu, et réexportez si le résultat ne convient pas. + +--- + +## 3. Choisir ce qui est joué + +La page a deux listes : + +**Jouées, dans cet ordre** : la « playlist » de la vitrine, jouée en boucle +de haut en bas. La fiche entourée de rouge est celle que la matrice affiche. + +| Bouton | Effet | +|---|---| +| ▲ ▼ | monter / descendre dans l'ordre de lecture | +| ▶ | jouer cette animation **tout de suite** (puis la liste reprend) | +| ⏸ | **ne plus la jouer** : elle passe dans « Pas jouées », sans être supprimée | +| ✕ | la mettre à la **corbeille** (une confirmation est demandée) | + +**Pas jouées** : la réserve. Les animations y attendent, prêtes à revenir. + +| Bouton | Effet | +|---|---| +| + | la remettre dans la liste (à la fin) | +| ▶ | la jouer **une fois**, tout de suite, sans la remettre dans la liste | +| ✕ | la mettre à la corbeille | + +**Corbeille** (en bas de la page) : les animations supprimées y restent tant +qu'on ne clique pas sur **Vider**. Vider la corbeille efface **définitivement**. + +--- + +## 4. Ce qu'il faut savoir + +- **Tout le monde sur le réseau peut modifier la vitrine.** Il n'y a pas de + mot de passe par défaut. Sur un réseau partagé (école, lieu public), + réglez-en un (partie 6). +- **Les changements sont immédiats** : le lecteur relit le dossier à chaque + tour. Après un changement d'ordre, la liste reprend depuis le début. +- **Avec l'overlay** (protection contre les coupures de courant, voir + [MODE_VITRINE.md](MODE_VITRINE.md#4-survivre-aux-coupures-de-courant)) : + si la partition de démarrage est protégée en écriture, la page ne peut plus + rien modifier et affiche une erreur. Pendant l'exposition, on peut choisir : + protection totale (et la page sert seulement à regarder et à jouer), ou + partition de démarrage modifiable (et la page reste pleinement utilisable). +- **Taille maximum** d'un envoi : 50 Mo (largement assez : un GIF de 8 × 32 + pèse quelques kilo-octets). + +--- + +## 5. Comment ça marche (pour les curieux) + +Deux programmes tournent en même temps sur le Pi, chacun dans son +**service** (lancé automatiquement au démarrage) : + +``` + navigateur ──(réseau)──▶ serveur_web.py lecteur.py ──▶ matrice + (service vitrine-web) (service vitrine-pixels) + │ ▲ + │ range les fichiers │ lit le dossier + ▼ │ à chaque tour + /boot/animations/ ─────────────────┘ + 01_coeur.gif, 02_vague/… + .desactivees/ ← « Pas jouées » (ignoré par le lecteur) + .corbeille/ ← corbeille (ignorée aussi) + + /run/vitrine-pixels/ (en mémoire vive, pas sur la carte SD) + en_cours ← écrit par le lecteur, lu par la page + demande ← écrit par la page (▶, Suivante), lu par le lecteur +``` + +- **L'ordre**, c'est l'ordre alphabétique des noms : quand on clique ▲ ou ▼, le + serveur **renomme** les fichiers `01_…`, `02_…`, `03_…` +- **Désactiver**, c'est déplacer le fichier dans `.desactivees`. Comme son + nom commence par un point, le lecteur l'ignore (c'est la même règle qui + lui fait ignorer les fichiers cachés des Mac). +- **Jouer maintenant** : le serveur dépose un « post-it » (le fichier + `demande`). Le lecteur le remarque en moins de 0,2 seconde, interrompt + l'animation en cours, et joue celle demandée. + +Le code : [`serveur_web.py`](serveur_web.py) (Python, avec la bibliothèque +**Flask**), la page [`web/page.html`](web/page.html) (HTML avec des « trous » +remplis par Python) et son style [`web/style.css`](web/style.css). Tout est +commenté. + +**L'essayer sur son ordinateur**, sans Pi : + +```bash +python3 serveur_web.py --port 8080 +``` + +puis ouvrir . Dans un autre terminal, +`python3 lecteur.py --simulateur` joue les animations dans une fenêtre et +réagit aux boutons de la page. (Il faut Flask : `pip install flask`.) + +--- + +## 6. Réglages et dépannage + +Les réglages sont en haut de [`serveur_web.py`](serveur_web.py) : + +| Réglage | Valeur | Effet | +|---|---|---| +| `MOT_DE_PASSE` | `None` | ex. `"pixels"` : le navigateur demande un nom (n'importe lequel) et ce mot de passe | +| `PORT` | `80` | le port web ; avec un autre, l'adresse devient `http://172.16.50.27:8080` | +| `TAILLE_MAX_MO` | `50` | taille maximum d'un envoi | + +Après une modification : `sudo systemctl restart vitrine-web`. + +| Problème | Solution | +|---|---| +| La page ne s'ouvre pas | Bonne adresse pour votre réseau (Wi-Fi ou câble, partie 1) ? Le service tourne-t-il ? `systemctl status vitrine-web` | +| « En ce moment : — » et rien ne bouge | Le **lecteur** ne tourne pas : `sudo systemctl enable --now vitrine-pixels` | +| ▶ ne fait rien | Idem : c'est le lecteur qui joue, la page ne fait que lui demander | +| « Impossible d'écrire dans le dossier des animations » | Partition protégée en écriture (overlay) : voir partie 4 | +| La page est lente au premier affichage | Normal sur un Pi 3 : il fabrique les aperçus. Ensuite, ils sont gardés en mémoire | +| Voir les erreurs du serveur | `journalctl -u vitrine-web -f` | diff --git a/8x32 PI/MODE_VITRINE.md b/8x32 PI/MODE_VITRINE.md index 1520ffe..22a6e9a 100644 --- a/8x32 PI/MODE_VITRINE.md +++ b/8x32 PI/MODE_VITRINE.md @@ -56,6 +56,7 @@ sudo systemctl disable --now vitrine-pixels | Je veux… | Commande | |---|---| | savoir si le lecteur tourne | `systemctl status vitrine-pixels` (`active (running)` = oui ; **q** pour quitter) | +| savoir si l'interface web tourne | `systemctl status vitrine-web` | | voir ce qu'il affiche, en direct | `journalctl -u vitrine-pixels -f` (**Ctrl + C** pour quitter) | | l'arrêter jusqu'au prochain démarrage | `sudo systemctl stop vitrine-pixels` | | le relancer (après avoir modifié un réglage) | `sudo systemctl restart vitrine-pixels` | @@ -79,6 +80,14 @@ le format des clés USB, que **Windows, macOS et Linux** savent tous lire et Le dossier est **relu à chaque tour de boucle** : pas besoin de redémarrer le lecteur après un changement. +### Méthode 0 : la page web (la plus simple) + +Ouvrez l'adresse du Pi dans un navigateur : `http://172.16.50.27` depuis le +Wi-Fi, `http://172.16.7.199` depuis le réseau filaire (voir +[INTERFACE_WEB.md, partie 1](INTERFACE_WEB.md#1-ouvrir-la-page)) : on y dépose les animations, on choisit celles qui sont jouées +et leur ordre. Tout est expliqué dans [INTERFACE_WEB.md](INTERFACE_WEB.md). +Les méthodes suivantes servent quand il n'y a pas de réseau. + ### Méthode 1 : la carte SD (sans réseau, sans terminal) C'est la méthode à donner à l'équipe design. diff --git a/8x32 PI/README.md b/8x32 PI/README.md index 87f847d..cbc68e4 100644 --- a/8x32 PI/README.md +++ b/8x32 PI/README.md @@ -14,6 +14,8 @@ dessinées dans **Krita** sont jouées telles quelles, sans les traduire en code > pour la matrice). Pas besoin de toucher au matériel ! > - **Pour la vitrine** : [MODE_VITRINE.md](MODE_VITRINE.md) (démarrage > automatique, changer les animations, coupures de courant). +> - **Pour déposer et choisir les animations depuis un navigateur** : +> [INTERFACE_WEB.md](INTERFACE_WEB.md) (adresse en Wi-Fi : `http://172.16.50.27`). > - **Pour qui veut coder** : [CODER_EN_PYTHON.md](CODER_EN_PYTHON.md). --- @@ -100,7 +102,8 @@ Côté **cerveau**, le Raspberry Pi fait tourner le programme [`lecteur.py`](lecteur.py), qui : 1. regarde ce qu'il y a dans le dossier `animations` ; -2. ouvre chaque fichier (GIF, PNG…) et le découpe en images de 32 × 8 ; +2. ouvre chaque fichier (GIF, PNG…) et le découpe en images de 8 × 32 + (la matrice est posée debout, voir partie 8) ; 3. pour chaque image, **corrige les couleurs** pour que la matrice ressemble à l'écran de Krita (la « correction gamma »), applique la luminosité et la limite de courant ; @@ -347,13 +350,16 @@ sudo ~/vitrine-env/bin/python test_matrice.py Le test se déroule en trois temps (voir [`test_matrice.py`](test_matrice.py)) : -**1. Les coins** (5 secondes). Posez la matrice **face LED vers vous** : +**1. Les coins** (5 secondes). La matrice est **posée debout**, comme dans +la vitrine, **face LED vers vous** (réglage `ROTATION = 90` dans +[`matrice.py`](matrice.py)). On doit voir : ``` - ROUGE ●· · · · · · · · · · · · · · · · ● VERT - · · · · · · · · · · · · · · · · · - · · · · · · · · · · · · · · · · · - BLEU ●· · · · · · · · · · · · · · · · ● BLANC + ROUGE ● · · · · · · ● VERT + · · · · · · · · + · · · · · · · · (32 lignes) + · · · · · · · · + BLEU ● · · · · · · ● BLANC ``` | Ce que vous voyez | Ce qu'il faut faire, dans [`matrice.py`](matrice.py) | @@ -361,7 +367,12 @@ Le test se déroule en trois temps (voir [`test_matrice.py`](test_matrice.py)) : | Rouge en haut à gauche, vert en haut à droite | rien, c'est parfait ✅ | | Rouge et vert inversés (gauche ↔ droite) | `INVERSER_X = True` | | Rouge et vert en bas | `INVERSER_Y = True` | -| Les deux à la fois | les deux à `True` | +| Les deux à la fois (image à l'envers) | les deux à `True` | +| Les couleurs sont sur les **grands côtés** (la matrice affiche « couché ») | `ROTATION` n'est pas à 90 : corrigez-la | + +> **Matrice couchée** (à l'horizontale, 32 de large × 8 de haut) : mettez +> `ROTATION = 0`. Tout le reste (Krita, lecteur, page web) suit +> automatiquement : les images se dessinent alors en 32 × 8. Pour modifier le fichier sur le Pi : `nano matrice.py`, changez la ligne, puis **Ctrl + O**, **Entrée** (enregistrer) et **Ctrl + X** (quitter). @@ -404,7 +415,8 @@ animation, pause entre deux, horaires) et de [`matrice.py`](matrice.py) : |---|---|---|---| | `LUMINOSITE` | matrice.py | `40` | de 0 à 255 ; montez en plein jour, en vitrine | | `COURANT_MAX_MA` | matrice.py | `2500` | un peu **sous** la limite de l'alimentation | -| `INVERSER_X`, `INVERSER_Y` | matrice.py | `False` | orientation (partie 8) | +| `ROTATION` | matrice.py | `90` | `90` = matrice debout (image 8 × 32), `0` = couchée (32 × 8) | +| `INVERSER_X`, `INVERSER_Y` | matrice.py | `False` | sens de l'image (partie 8) | | `GAMMA` | matrice.py | `2.2` | correction des couleurs (1.0 = aucune) | | `DUREE_MINIMUM` | lecteur.py | `10` | une animation courte est rejouée pendant au moins 10 s | | `HORAIRES` | lecteur.py | `None` | ex. `("09:00", "22:30")` | @@ -484,9 +496,12 @@ Pour voir ce que fait le lecteur quand il tourne tout seul (mode vitrine) : | [INSTALLATION_PI.md](INSTALLATION_PI.md) | électronique | préparer la carte SD et le Pi, pas à pas | | [GUIDE_KRITA.md](GUIDE_KRITA.md) | design | dessiner, exporter, vérifier pour la matrice | | [MODE_VITRINE.md](MODE_VITRINE.md) | tous | démarrage automatique, changer les animations, entretien | +| [INTERFACE_WEB.md](INTERFACE_WEB.md) | tous | la page web : déposer, ordonner, activer, jouer les animations | | [CODER_EN_PYTHON.md](CODER_EN_PYTHON.md) | code | créer des animations en Python ; comparaison avec l'Arduino | | [`matrice.py`](matrice.py) | | le moteur : réglages, `XY()`, gamma, vraie matrice et simulateur | | [`lecteur.py`](lecteur.py) | | joue les animations du dossier en boucle | +| [`serveur_web.py`](serveur_web.py) | | l'interface web (Flask) : dépôt et choix des animations | +| [`web/`](web/) | | la page web : `page.html`, `infos.html`, `style.css` | | [`test_matrice.py`](test_matrice.py) | | test de branchement et d'orientation | | [`mes_animations.py`](mes_animations.py) | | les animations de l'Arduino, traduites en Python | | [`animations/`](animations/) | | exemples : GIF, séquence de PNG, APNG | @@ -495,7 +510,7 @@ Pour voir ce que fait le lecteur quand il tourne tout seul (mode vitrine) : | [`outils/verifier_animation.py`](outils/verifier_animation.py) | design | vérifie un export et crée un aperçu « façon LED » | | [`outils/generer_exemples.py`](outils/generer_exemples.py) | | recrée les exemples et la palette | | [`outils/schemas.py`](outils/schemas.py) | | redessine les schémas du dossier `schemas/` | -| [`installation/`](installation/) | électronique | script d'installation et service de démarrage automatique | +| [`installation/`](installation/) | électronique | script d'installation, services de démarrage automatique (lecteur et interface web) | --- diff --git a/8x32 PI/animations/01_coeur_qui_bat.gif b/8x32 PI/animations/01_coeur_qui_bat.gif index cd2c790..51ef3d8 100644 Binary files a/8x32 PI/animations/01_coeur_qui_bat.gif and b/8x32 PI/animations/01_coeur_qui_bat.gif differ diff --git a/8x32 PI/animations/02_texte_defilant.gif b/8x32 PI/animations/02_texte_defilant.gif index 0167880..d9c7240 100644 Binary files a/8x32 PI/animations/02_texte_defilant.gif and b/8x32 PI/animations/02_texte_defilant.gif differ diff --git a/8x32 PI/animations/03_vague/vague_0000.png b/8x32 PI/animations/03_vague/vague_0000.png index 0adfd74..9456bbb 100644 Binary files a/8x32 PI/animations/03_vague/vague_0000.png and b/8x32 PI/animations/03_vague/vague_0000.png differ diff --git a/8x32 PI/animations/03_vague/vague_0001.png b/8x32 PI/animations/03_vague/vague_0001.png index fe42bde..00e03b9 100644 Binary files a/8x32 PI/animations/03_vague/vague_0001.png and b/8x32 PI/animations/03_vague/vague_0001.png differ diff --git a/8x32 PI/animations/03_vague/vague_0002.png b/8x32 PI/animations/03_vague/vague_0002.png index aa89e9b..999440f 100644 Binary files a/8x32 PI/animations/03_vague/vague_0002.png and b/8x32 PI/animations/03_vague/vague_0002.png differ diff --git a/8x32 PI/animations/03_vague/vague_0003.png b/8x32 PI/animations/03_vague/vague_0003.png index 8dbdec5..2b15f90 100644 Binary files a/8x32 PI/animations/03_vague/vague_0003.png and b/8x32 PI/animations/03_vague/vague_0003.png differ diff --git a/8x32 PI/animations/03_vague/vague_0004.png b/8x32 PI/animations/03_vague/vague_0004.png index a651eb5..e991c9f 100644 Binary files a/8x32 PI/animations/03_vague/vague_0004.png and b/8x32 PI/animations/03_vague/vague_0004.png differ diff --git a/8x32 PI/animations/03_vague/vague_0005.png b/8x32 PI/animations/03_vague/vague_0005.png index 41fa19d..9ca2bfc 100644 Binary files a/8x32 PI/animations/03_vague/vague_0005.png and b/8x32 PI/animations/03_vague/vague_0005.png differ diff --git a/8x32 PI/animations/03_vague/vague_0006.png b/8x32 PI/animations/03_vague/vague_0006.png index a58010b..8ddc7d3 100644 Binary files a/8x32 PI/animations/03_vague/vague_0006.png and b/8x32 PI/animations/03_vague/vague_0006.png differ diff --git a/8x32 PI/animations/03_vague/vague_0007.png b/8x32 PI/animations/03_vague/vague_0007.png index 32eaf88..2aa7229 100644 Binary files a/8x32 PI/animations/03_vague/vague_0007.png and b/8x32 PI/animations/03_vague/vague_0007.png differ diff --git a/8x32 PI/animations/03_vague/vague_0008.png b/8x32 PI/animations/03_vague/vague_0008.png index 61400d2..c95e67b 100644 Binary files a/8x32 PI/animations/03_vague/vague_0008.png and b/8x32 PI/animations/03_vague/vague_0008.png differ diff --git a/8x32 PI/animations/03_vague/vague_0009.png b/8x32 PI/animations/03_vague/vague_0009.png index c1b3f53..2ad2564 100644 Binary files a/8x32 PI/animations/03_vague/vague_0009.png and b/8x32 PI/animations/03_vague/vague_0009.png differ diff --git a/8x32 PI/animations/03_vague/vague_0010.png b/8x32 PI/animations/03_vague/vague_0010.png index 5766e6d..630dcad 100644 Binary files a/8x32 PI/animations/03_vague/vague_0010.png and b/8x32 PI/animations/03_vague/vague_0010.png differ diff --git a/8x32 PI/animations/03_vague/vague_0011.png b/8x32 PI/animations/03_vague/vague_0011.png index ed0e671..0939718 100644 Binary files a/8x32 PI/animations/03_vague/vague_0011.png and b/8x32 PI/animations/03_vague/vague_0011.png differ diff --git a/8x32 PI/animations/03_vague/vague_0012.png b/8x32 PI/animations/03_vague/vague_0012.png index 95a461c..03260d9 100644 Binary files a/8x32 PI/animations/03_vague/vague_0012.png and b/8x32 PI/animations/03_vague/vague_0012.png differ diff --git a/8x32 PI/animations/03_vague/vague_0013.png b/8x32 PI/animations/03_vague/vague_0013.png index f70f24a..7955d9d 100644 Binary files a/8x32 PI/animations/03_vague/vague_0013.png and b/8x32 PI/animations/03_vague/vague_0013.png differ diff --git a/8x32 PI/animations/03_vague/vague_0014.png b/8x32 PI/animations/03_vague/vague_0014.png index e52013e..3342616 100644 Binary files a/8x32 PI/animations/03_vague/vague_0014.png and b/8x32 PI/animations/03_vague/vague_0014.png differ diff --git a/8x32 PI/animations/03_vague/vague_0015.png b/8x32 PI/animations/03_vague/vague_0015.png index 1cb8b95..3c0a922 100644 Binary files a/8x32 PI/animations/03_vague/vague_0015.png and b/8x32 PI/animations/03_vague/vague_0015.png differ diff --git a/8x32 PI/animations/03_vague/vague_0016.png b/8x32 PI/animations/03_vague/vague_0016.png index 4c910a5..646a5b8 100644 Binary files a/8x32 PI/animations/03_vague/vague_0016.png and b/8x32 PI/animations/03_vague/vague_0016.png differ diff --git a/8x32 PI/animations/03_vague/vague_0017.png b/8x32 PI/animations/03_vague/vague_0017.png index 9f2f3e3..8d0d918 100644 Binary files a/8x32 PI/animations/03_vague/vague_0017.png and b/8x32 PI/animations/03_vague/vague_0017.png differ diff --git a/8x32 PI/animations/03_vague/vague_0018.png b/8x32 PI/animations/03_vague/vague_0018.png index 52d74f4..6f29442 100644 Binary files a/8x32 PI/animations/03_vague/vague_0018.png and b/8x32 PI/animations/03_vague/vague_0018.png differ diff --git a/8x32 PI/animations/03_vague/vague_0019.png b/8x32 PI/animations/03_vague/vague_0019.png index 0c9943e..a0193c5 100644 Binary files a/8x32 PI/animations/03_vague/vague_0019.png and b/8x32 PI/animations/03_vague/vague_0019.png differ diff --git a/8x32 PI/animations/03_vague/vague_0020.png b/8x32 PI/animations/03_vague/vague_0020.png index 06441ed..d015336 100644 Binary files a/8x32 PI/animations/03_vague/vague_0020.png and b/8x32 PI/animations/03_vague/vague_0020.png differ diff --git a/8x32 PI/animations/03_vague/vague_0021.png b/8x32 PI/animations/03_vague/vague_0021.png index 79e307a..88b5ebe 100644 Binary files a/8x32 PI/animations/03_vague/vague_0021.png and b/8x32 PI/animations/03_vague/vague_0021.png differ diff --git a/8x32 PI/animations/03_vague/vague_0022.png b/8x32 PI/animations/03_vague/vague_0022.png index 384d9e7..e08eb48 100644 Binary files a/8x32 PI/animations/03_vague/vague_0022.png and b/8x32 PI/animations/03_vague/vague_0022.png differ diff --git a/8x32 PI/animations/03_vague/vague_0023.png b/8x32 PI/animations/03_vague/vague_0023.png index 7f4808d..4798ef3 100644 Binary files a/8x32 PI/animations/03_vague/vague_0023.png and b/8x32 PI/animations/03_vague/vague_0023.png differ diff --git a/8x32 PI/animations/03_vague/vague_0024.png b/8x32 PI/animations/03_vague/vague_0024.png index 63ff437..200251e 100644 Binary files a/8x32 PI/animations/03_vague/vague_0024.png and b/8x32 PI/animations/03_vague/vague_0024.png differ diff --git a/8x32 PI/animations/03_vague/vague_0025.png b/8x32 PI/animations/03_vague/vague_0025.png index 6334dce..7542629 100644 Binary files a/8x32 PI/animations/03_vague/vague_0025.png and b/8x32 PI/animations/03_vague/vague_0025.png differ diff --git a/8x32 PI/animations/03_vague/vague_0026.png b/8x32 PI/animations/03_vague/vague_0026.png index 1e9bf87..7f3adaf 100644 Binary files a/8x32 PI/animations/03_vague/vague_0026.png and b/8x32 PI/animations/03_vague/vague_0026.png differ diff --git a/8x32 PI/animations/03_vague/vague_0027.png b/8x32 PI/animations/03_vague/vague_0027.png index f478e3c..76d1684 100644 Binary files a/8x32 PI/animations/03_vague/vague_0027.png and b/8x32 PI/animations/03_vague/vague_0027.png differ diff --git a/8x32 PI/animations/03_vague/vague_0028.png b/8x32 PI/animations/03_vague/vague_0028.png index b0e479f..635e514 100644 Binary files a/8x32 PI/animations/03_vague/vague_0028.png and b/8x32 PI/animations/03_vague/vague_0028.png differ diff --git a/8x32 PI/animations/03_vague/vague_0029.png b/8x32 PI/animations/03_vague/vague_0029.png index 3f653b3..d8c217c 100644 Binary files a/8x32 PI/animations/03_vague/vague_0029.png and b/8x32 PI/animations/03_vague/vague_0029.png differ diff --git a/8x32 PI/animations/03_vague/vague_0030.png b/8x32 PI/animations/03_vague/vague_0030.png index 8ffd215..a630baa 100644 Binary files a/8x32 PI/animations/03_vague/vague_0030.png and b/8x32 PI/animations/03_vague/vague_0030.png differ diff --git a/8x32 PI/animations/03_vague/vague_0031.png b/8x32 PI/animations/03_vague/vague_0031.png index c03aa1d..e58873f 100644 Binary files a/8x32 PI/animations/03_vague/vague_0031.png and b/8x32 PI/animations/03_vague/vague_0031.png differ diff --git a/8x32 PI/animations/03_vague/vague_0032.png b/8x32 PI/animations/03_vague/vague_0032.png index 3f00d1f..416c496 100644 Binary files a/8x32 PI/animations/03_vague/vague_0032.png and b/8x32 PI/animations/03_vague/vague_0032.png differ diff --git a/8x32 PI/animations/03_vague/vague_0033.png b/8x32 PI/animations/03_vague/vague_0033.png index f1e47aa..78fdc73 100644 Binary files a/8x32 PI/animations/03_vague/vague_0033.png and b/8x32 PI/animations/03_vague/vague_0033.png differ diff --git a/8x32 PI/animations/03_vague/vague_0034.png b/8x32 PI/animations/03_vague/vague_0034.png index 5a5e519..9b1d1b6 100644 Binary files a/8x32 PI/animations/03_vague/vague_0034.png and b/8x32 PI/animations/03_vague/vague_0034.png differ diff --git a/8x32 PI/animations/03_vague/vague_0035.png b/8x32 PI/animations/03_vague/vague_0035.png index bb107f6..3ef76de 100644 Binary files a/8x32 PI/animations/03_vague/vague_0035.png and b/8x32 PI/animations/03_vague/vague_0035.png differ diff --git a/8x32 PI/animations/03_vague/vague_0036.png b/8x32 PI/animations/03_vague/vague_0036.png index cac54de..4ece814 100644 Binary files a/8x32 PI/animations/03_vague/vague_0036.png and b/8x32 PI/animations/03_vague/vague_0036.png differ diff --git a/8x32 PI/animations/03_vague/vague_0037.png b/8x32 PI/animations/03_vague/vague_0037.png index e2e2712..dcd5f55 100644 Binary files a/8x32 PI/animations/03_vague/vague_0037.png and b/8x32 PI/animations/03_vague/vague_0037.png differ diff --git a/8x32 PI/animations/03_vague/vague_0038.png b/8x32 PI/animations/03_vague/vague_0038.png index 1afa413..4a540ad 100644 Binary files a/8x32 PI/animations/03_vague/vague_0038.png and b/8x32 PI/animations/03_vague/vague_0038.png differ diff --git a/8x32 PI/animations/03_vague/vague_0039.png b/8x32 PI/animations/03_vague/vague_0039.png index f848a9e..16cb353 100644 Binary files a/8x32 PI/animations/03_vague/vague_0039.png and b/8x32 PI/animations/03_vague/vague_0039.png differ diff --git a/8x32 PI/animations/04_balle.png b/8x32 PI/animations/04_balle.png index 892d737..4b56de7 100644 Binary files a/8x32 PI/animations/04_balle.png and b/8x32 PI/animations/04_balle.png differ diff --git a/8x32 PI/installation/installer.sh b/8x32 PI/installation/installer.sh index 455b015..cb1d43c 100755 --- a/8x32 PI/installation/installer.sh +++ b/8x32 PI/installation/installer.sh @@ -35,10 +35,10 @@ echo "=== Partition de démarrage : $BOOT" echo echo "=== 1/5 Installation des logiciels nécessaires (quelques minutes)" -# python3-pil = Pillow (lire les images) ; python3-dev et gcc = pour compiler -# la bibliothèque rpi_ws281x, qui parle aux LED. +# python3-pil = Pillow (lire les images) ; python3-flask = le serveur web ; +# python3-dev et gcc = pour compiler la bibliothèque rpi_ws281x, qui parle aux LED. sudo apt-get update -sudo apt-get install -y python3-venv python3-pil python3-dev gcc +sudo apt-get install -y python3-venv python3-pil python3-flask python3-dev gcc echo echo "=== 2/5 Environnement Python dans $ENV" @@ -73,14 +73,22 @@ else fi echo -echo "=== 5/5 Service de démarrage automatique « vitrine-pixels »" -# On recopie le modèle de service en remplaçant @PROJET@ et @PYTHON@ +echo "=== 5/5 Services de démarrage automatique : lecteur et interface web" +# On recopie chaque modèle de service en remplaçant @PROJET@ et @PYTHON@ # par les vrais chemins. -sed -e "s|@PROJET@|$PROJET|g" -e "s|@PYTHON@|$ENV/bin/python|g" \ - "$PROJET/installation/vitrine-pixels.service" \ - | sudo tee /etc/systemd/system/vitrine-pixels.service > /dev/null +for SERVICE in vitrine-pixels vitrine-web; do + sed -e "s|@PROJET@|$PROJET|g" -e "s|@PYTHON@|$ENV/bin/python|g" \ + "$PROJET/installation/$SERVICE.service" \ + | sudo tee "/etc/systemd/system/$SERVICE.service" > /dev/null +done sudo systemctl daemon-reload -echo " service installé (pas encore activé)" +# L'interface web ne touche pas à la matrice : on peut l'activer tout de suite. +sudo systemctl enable --now vitrine-web +sudo systemctl restart vitrine-web +for ADRESSE in $(hostname -I); do + case "$ADRESSE" in *.*) echo " interface web : http://$ADRESSE" ;; esac +done +echo " lecteur installé (pas encore activé : testez d'abord la matrice)" echo echo "================================================================" diff --git a/8x32 PI/installation/vitrine-web.service b/8x32 PI/installation/vitrine-web.service new file mode 100644 index 0000000..0512c72 --- /dev/null +++ b/8x32 PI/installation/vitrine-web.service @@ -0,0 +1,31 @@ +# ============================================================================= +# Service « vitrine-web » : l'interface web (serveur_web.py) +# ============================================================================= +# +# Comme vitrine-pixels.service, c'est un MODÈLE : installation/installer.sh +# remplace @PROJET@ et @PYTHON@ par les vrais chemins. +# +# Il tourne À CÔTÉ du lecteur (service vitrine-pixels) : le serveur range les +# fichiers et laisse des « post-it », le lecteur joue les animations. +# +# Commandes utiles : +# sudo systemctl enable --now vitrine-web activer + démarrer +# sudo systemctl restart vitrine-web relancer (après une modification) +# journalctl -u vitrine-web -f voir les visites et les erreurs + +[Unit] +Description=Vitrine pixels : interface web de gestion des animations +After=network.target + +[Service] +Type=simple +ExecStart="@PYTHON@" "@PROJET@/serveur_web.py" +WorkingDirectory=@PROJET@ +# Administrateur : pour écrire dans /boot et utiliser le port 80. +User=root +Restart=always +RestartSec=5 +Environment=PYTHONUNBUFFERED=1 + +[Install] +WantedBy=multi-user.target diff --git a/8x32 PI/lecteur.py b/8x32 PI/lecteur.py index 1ad2007..03e2e53 100644 --- a/8x32 PI/lecteur.py +++ b/8x32 PI/lecteur.py @@ -42,13 +42,17 @@ ORGANISATION DU PROGRAMME 2. Trouver les animations dans le dossier. 3. Charger une animation : la découper en images de 32 × 8. 4. Jouer une animation. - 5. Les horaires (allumer la vitrine seulement à certaines heures). - 6. Le programme principal. + 5. Parler avec le serveur web (serveur_web.py). + 6. Les horaires (allumer la vitrine seulement à certaines heures). + 7. Le programme principal. """ import argparse # pour lire les options (--simulateur, --dossier…) +import sys import datetime # pour connaître l'heure (horaires de la vitrine) import os # pour parcourir les dossiers et les fichiers +import signal # pour réagir quand Linux demande au programme de s'arrêter +import tempfile # pour trouver le dossier temporaire de l'ordinateur import time from PIL import Image, ImageSequence @@ -169,7 +173,17 @@ def adapter_image(image, nom): and largeur // LARGEUR == hauteur // HAUTEUR: return image.resize((LARGEUR, HAUTEUR), PLUS_PROCHE_VOISIN) - # 3) Autre taille (par exemple 39 × 7, le format final) : on la pose + # 3) L'image est dessinée dans l'AUTRE SENS (couchée, 32 × 8, alors que la + # matrice est debout, ou l'inverse) : on la fait tourner d'un quart de + # tour, comme un texte sur le dos d'un livre (il se lit de bas en haut). + if LARGEUR != HAUTEUR and largeur % HAUTEUR == 0 and hauteur % LARGEUR == 0 \ + and largeur // HAUTEUR == hauteur // LARGEUR: + avertir(nom, f"image couchée ({largeur} × {hauteur}) sur une matrice " + f"{'debout' if HAUTEUR > LARGEUR else 'couchée'} : tournée d'un quart de tour") + image = image.transpose(UN_QUART_DE_TOUR) + return image.resize((LARGEUR, HAUTEUR), PLUS_PROCHE_VOISIN) + + # 4) Autre taille (par exemple 39 × 7, le format final) : on la pose # au centre, sans la déformer. Ce qui dépasse est coupé. avertir(nom, f"taille {largeur} × {hauteur} au lieu de {LARGEUR} × {HAUTEUR} : " "image centrée (ce qui dépasse est coupé)") @@ -181,6 +195,8 @@ def adapter_image(image, nom): # Le nom du filtre « plus proche voisin » a changé avec Pillow 9.1 : # on prend le nouveau s'il existe, sinon l'ancien (Raspbian 11 a Pillow 8). PLUS_PROCHE_VOISIN = getattr(Image, "Resampling", Image).NEAREST +# Idem pour « tourner d'un quart de tour dans le sens inverse des aiguilles d'une montre ». +UN_QUART_DE_TOUR = getattr(Image, "Transpose", Image).ROTATE_90 _deja_averti = set() @@ -241,18 +257,52 @@ def charger(chemin): # ============================================================================= def jouer(etapes, duree_minimum=DUREE_MINIMUM): - """Joue la liste d'étapes, et la rejoue jusqu'à atteindre la durée minimum.""" + """Joue la liste d'étapes, et la rejoue jusqu'à atteindre la durée minimum. + S'arrête plus tôt si le serveur web demande autre chose (partie 5).""" debut = time.monotonic() while True: for image, duree in etapes: moment = time.monotonic() matrice.montrer(image) # On retire le temps déjà passé à afficher, pour garder le rythme. - matrice.attendre((duree - (time.monotonic() - moment)) * 1000) + if not attendre_ou_interrompre(duree - (time.monotonic() - moment)): + return if time.monotonic() - debut >= duree_minimum: break +def attendre_ou_interrompre(secondes): + """Attend, par petits bouts de 0,2 s, en surveillant les demandes du + serveur web. Renvoie False si une demande est arrivée entre-temps.""" + fin = time.monotonic() + secondes + while True: + if demande_en_attente(): + return False + reste = fin - time.monotonic() + if reste <= 0: + return True + matrice.attendre(min(reste, 0.2) * 1000) + + +def jouer_animation(chemin, dossier): + """Charge et joue UNE animation (fichier ou séquence), puis un court noir.""" + nom = os.path.relpath(chemin, dossier) + print("▶", nom) + ecrire_en_cours(nom) + try: + etapes = charger(chemin) + except Exception as erreur: + # Un fichier abîmé ne doit pas arrêter la vitrine : on le saute. + print(f" ✗ impossible de lire ce fichier ({erreur}) : on passe au suivant") + return + if etapes: + jouer(etapes) + if PAUSE_ENTRE_ANIMATIONS > 0: + matrice.effacer() + matrice.montrer() + matrice.attendre(PAUSE_ENTRE_ANIMATIONS * 1000) + + def ecran_d_attente(dossier): """Quand il n'y a rien à jouer : un petit point qui respire en bas à droite.""" print(f"Aucune animation dans {dossier} : en attente…") @@ -261,11 +311,88 @@ def ecran_d_attente(dossier): lumiere = int(60 + 60 * abs(25 - i) / 25) matrice.pixel(LARGEUR - 1, HAUTEUR - 1, (0, lumiere, lumiere)) matrice.montrer() - matrice.attendre(100) + if not attendre_ou_interrompre(0.1): + return # une demande est arrivée depuis la page web # ============================================================================= -# 5. LES HORAIRES +# 5. PARLER AVEC LE SERVEUR WEB +# ============================================================================= +# +# Le serveur web (serveur_web.py) et le lecteur sont deux programmes séparés, +# qui tournent en même temps. Pour se parler, ils s'échangent de tout petits +# fichiers, comme des post-it, dans un dossier qui est en MÉMOIRE VIVE +# (/run/vitrine-pixels) : rien n'est écrit sur la carte SD. +# - « en_cours » : le lecteur y écrit le nom de l'animation qu'il joue, +# pour que la page web puisse l'afficher ; +# - « demande » : le serveur y écrit le nom d'une animation à jouer +# TOUT DE SUITE, ou « suivante » pour passer à la suivante. +# Le lecteur la lit, puis efface le post-it. + +SUIVANTE = "suivante" + + +def trouver_dossier_etat(): + """/run/vitrine-pixels sur le Pi ; le dossier temporaire sur un ordinateur + où l'on n'a pas le droit d'écrire dans /run.""" + for dossier in ("/run/vitrine-pixels", + os.path.join(tempfile.gettempdir(), "vitrine-pixels")): + try: + os.makedirs(dossier, exist_ok=True) + if os.access(dossier, os.W_OK): + return dossier + except OSError: + pass + return tempfile.gettempdir() + + +DOSSIER_ETAT = trouver_dossier_etat() +FICHIER_EN_COURS = os.path.join(DOSSIER_ETAT, "en_cours") +FICHIER_DEMANDE = os.path.join(DOSSIER_ETAT, "demande") + + +def ecrire_post_it(fichier, texte): + try: + with open(fichier, "w") as f: + f.write(texte) + except OSError: + pass # pas grave : la page web n'affichera simplement pas l'info + + +def ecrire_en_cours(nom): + ecrire_post_it(FICHIER_EN_COURS, nom) + + +def demande_en_attente(): + return os.path.exists(FICHIER_DEMANDE) + + +def lire_demande(): + """Renvoie la demande du serveur web (et l'efface), ou None s'il n'y en a pas.""" + try: + with open(FICHIER_DEMANDE) as f: + demande = f.read().strip() + os.remove(FICHIER_DEMANDE) + return demande or None + except OSError: + return None + + +def traiter_demandes(dossier): + """Joue les animations demandées depuis la page web, s'il y en a.""" + demande = lire_demande() + while demande: + if demande != SUIVANTE: + chemin = os.path.join(dossier, demande) + if os.path.exists(chemin): + jouer_animation(chemin, dossier) + else: + print(" ✗ animation demandée introuvable :", demande) + demande = lire_demande() + + +# ============================================================================= +# 6. LES HORAIRES # ============================================================================= def en_horaires(): @@ -280,7 +407,7 @@ def en_horaires(): # ============================================================================= -# 6. LE PROGRAMME PRINCIPAL +# 7. LE PROGRAMME PRINCIPAL # ============================================================================= def main(): @@ -295,33 +422,33 @@ def main(): matrice.demarrer(simulateur=args.simulateur) dossier = args.dossier or trouver_dossier() + # Quand le service est arrêté (systemctl stop, extinction du Pi), Linux + # envoie le signal « SIGTERM ». Par défaut, Python s'arrêterait net, sans + # passer par « finally » : la matrice resterait figée sur la dernière image. + # On transforme ce signal en sortie normale du programme. + signal.signal(signal.SIGTERM, lambda numero, cadre: sys.exit(0)) + try: while True: # boucle infinie : la vitrine tourne jusqu'à ce qu'on l'arrête if not en_horaires(): + ecrire_en_cours("(éteint : en dehors des horaires)") matrice.eteindre() matrice.attendre(30_000) # on revérifie l'heure toutes les 30 s continue # Un seul fichier demandé, ou tout le dossier ? - playlist = [args.chemin] if args.chemin else lister_animations(dossier) + playlist = [os.path.abspath(args.chemin)] if args.chemin \ + else lister_animations(dossier) if not playlist: + ecrire_en_cours("(rien à jouer)") ecran_d_attente(dossier) + traiter_demandes(dossier) continue for chemin in playlist: - print("▶", os.path.relpath(chemin, dossier) if not args.chemin else chemin) - try: - etapes = charger(chemin) - except Exception as erreur: - # Un fichier abîmé ne doit pas arrêter la vitrine : on le saute. - print(f" ✗ impossible de lire ce fichier ({erreur}) : on passe au suivant") - continue - if etapes: - jouer(etapes) - if PAUSE_ENTRE_ANIMATIONS > 0: - matrice.effacer() - matrice.montrer() - matrice.attendre(PAUSE_ENTRE_ANIMATIONS * 1000) + traiter_demandes(dossier) # « jouer maintenant » depuis le web ? + if os.path.exists(chemin): # (elle a pu être supprimée entre-temps) + jouer_animation(chemin, dossier) if not en_horaires(): break @@ -333,6 +460,7 @@ def main(): # on ne laisse jamais la matrice allumée en partant. try: matrice.eteindre() + os.remove(FICHIER_EN_COURS) except Exception: pass diff --git a/8x32 PI/matrice.py b/8x32 PI/matrice.py index a5ca469..ae1858e 100644 --- a/8x32 PI/matrice.py +++ b/8x32 PI/matrice.py @@ -68,10 +68,25 @@ from PIL import Image # 1. RÉGLAGES # ============================================================================= -# Taille de la matrice : 32 colonnes (largeur) × 8 lignes (hauteur). -LARGEUR = 32 -HAUTEUR = 8 -NB_LEDS = LARGEUR * HAUTEUR # = 256 +# Le PANNEAU, tel qu'il est fabriqué : 32 colonnes × 8 lignes de LED, +# câblées en serpentin par colonnes de 8. Ça ne change jamais. +COLONNES_PANNEAU = 32 +LIGNES_PANNEAU = 8 +NB_LEDS = COLONNES_PANNEAU * LIGNES_PANNEAU # = 256 + +# ROTATION : comment le panneau est POSÉ dans la vitrine, en degrés. +# 0 = couché (à l'horizontale) : l'image fait 32 de large × 8 de haut ; +# 90 = debout (à la verticale) : l'image fait 8 de large × 32 de haut. +# (180 et 270 existent aussi : couché à l'envers, debout dans l'autre sens. +# Pour remettre l'image à l'endroit, INVERSER_X / INVERSER_Y suffisent.) +ROTATION = 90 + +# Taille de l'IMAGE à dessiner (dans Krita, dans le code…), selon la rotation. +# Tout le reste du programme utilise LARGEUR et HAUTEUR. +if ROTATION in (90, 270): + LARGEUR, HAUTEUR = LIGNES_PANNEAU, COLONNES_PANNEAU # 8 × 32 : debout +else: + LARGEUR, HAUTEUR = COLONNES_PANNEAU, LIGNES_PANNEAU # 32 × 8 : couché # Broche du Raspberry Pi reliée (à travers l'adaptateur 74AHCT125) au fil # VERT (DIN) de la matrice. Ce numéro est le numéro « GPIO », pas le numéro @@ -101,10 +116,11 @@ LUMINOSITE = 40 # (ex. : alimentation d'établi limitée à 3 A → 2500 mA ici). COURANT_MAX_MA = 2500 -# ORIENTATION de la matrice (voir test_matrice.py). +# SENS de l'image (voir test_matrice.py), une fois ROTATION choisie. # Le pixel ROUGE du test doit être EN HAUT À GAUCHE, le VERT EN HAUT À DROITE. # - rouge et vert inversés (gauche/droite) → INVERSER_X = True # - rouge et vert en bas au lieu d'en haut → INVERSER_Y = True +# - les deux à la fois (image à l'envers) → les deux à True INVERSER_X = False INVERSER_Y = False @@ -159,35 +175,57 @@ def teinte(t, saturation=255, valeur=255): # 3. DE LA POSITION (x, y) AU NUMÉRO DE LED # ============================================================================= # -# Pour dessiner, on pense en coordonnées : -# x = colonne, de 0 (à gauche) à 31 (à droite) -# y = ligne, de 0 (en haut) à 7 (en bas) +# Pour dessiner, on pense en coordonnées, sur l'IMAGE telle qu'on la voit : +# x = colonne, de 0 (à gauche) à LARGEUR - 1 (à droite) +# y = ligne, de 0 (en haut) à HAUTEUR - 1 (en bas) # Le point (0, 0) est en haut à gauche, comme dans Krita. # -# Mais les LED sont numérotées dans l'ordre de la chaîne, qui fait des -# zigzags (« serpentin ») : elle descend la 1re colonne, remonte la 2e… +# Pour trouver la LED correspondante, trois étapes : # -# x=0 x=1 x=2 x=3 -# y=0 0 15 16 31 … -# y=1 1 14 17 30 -# … … … … … -# y=7 7 → 8 23 → 24 +# 1. Retourner l'image si besoin (INVERSER_X / INVERSER_Y). # -# C'est la même fonction que dans matrice_8x32.ino, traduite en Python. +# 2. Tourner : passer de la position sur l'IMAGE à la position sur le +# PANNEAU (px de 0 à 31, py de 0 à 7, panneau vu couché). Exemple, +# panneau debout (ROTATION = 90, tourné d'un quart de tour vers la +# droite) : le haut de l'image correspond à la gauche du panneau, et la +# droite de l'image au haut du panneau. +# +# 3. Le serpentin : les LED sont numérotées dans l'ordre de la chaîne, qui +# descend la 1re colonne du panneau, remonte la 2e… +# +# px=0 px=1 px=2 px=3 +# py=0 0 15 16 31 … +# py=1 1 14 17 30 +# … … … … … +# py=7 7 → 8 23 → 24 +# +# L'étape 3 est la même fonction que dans matrice_8x32.ino. def XY(x, y): - """Renvoie le numéro (0 à 255) de la LED placée en colonne x, ligne y.""" + """Renvoie le numéro (0 à 255) de la LED qui affiche le pixel (x, y) de l'image.""" + # 1. Retourner. if INVERSER_X: x = (LARGEUR - 1) - x if INVERSER_Y: y = (HAUTEUR - 1) - y - if x % 2 == 0: - # Colonne paire : la chaîne DESCEND. - return x * HAUTEUR + y + # 2. Tourner : position (px, py) sur le panneau couché. + if ROTATION == 90: + px, py = y, (LIGNES_PANNEAU - 1) - x + elif ROTATION == 180: + px, py = (COLONNES_PANNEAU - 1) - x, (LIGNES_PANNEAU - 1) - y + elif ROTATION == 270: + px, py = (COLONNES_PANNEAU - 1) - y, x + else: + px, py = x, y + + # 3. Le serpentin. + if px % 2 == 0: + # Colonne paire du panneau : la chaîne DESCEND. + return px * LIGNES_PANNEAU + py else: # Colonne impaire : la chaîne REMONTE. - return x * HAUTEUR + (HAUTEUR - 1 - y) + return px * LIGNES_PANNEAU + (LIGNES_PANNEAU - 1 - py) # ============================================================================= @@ -316,7 +354,9 @@ class MatriceReelle: class Simulateur: """Une fenêtre sur l'ordinateur qui imite la matrice (256 ronds).""" - TAILLE = 26 # taille d'un pixel à l'écran, en points + # Taille d'un pixel à l'écran, en points : plus petit si l'image est + # haute (panneau debout), pour que la fenêtre tienne dans l'écran. + TAILLE = min(26, 640 // max(LARGEUR, HAUTEUR)) MARGE = 4 # espace entre deux pixels def __init__(self, titre="Simulateur — matrice 8 × 32"): diff --git a/8x32 PI/mes_animations.py b/8x32 PI/mes_animations.py index 2bf6372..abe430c 100644 --- a/8x32 PI/mes_animations.py +++ b/8x32 PI/mes_animations.py @@ -67,8 +67,8 @@ def anim_arc_en_ciel(): for temps in range(256): for x in range(LARGEUR): for y in range(HAUTEUR): - # La teinte dépend de la colonne et du temps qui passe. - pixel(x, y, teinte(x * 8 + temps * 2)) + # La teinte dépend de la position et du temps qui passe. + pixel(x, y, teinte((x + y) * 8 + temps * 2)) montrer() attendre(20) @@ -95,16 +95,16 @@ def anim_pluie(): # --- Animation 3 : un dessin en pixel art qui défile --- -# Une lettre par pixel, « . » = éteint. 8 lignes de même longueur. +# Une lettre par pixel, « . » = éteint. Toutes les lignes de même longueur, +# pas plus de 8 pixels de large ni de haut (le petit côté de la matrice). DESSIN = [ - "..RR.RR..", - ".RBRRRRR.", - ".RRRRRRR.", - ".RRRRRRR.", - "..RRRRR..", - "...RRR...", - "....R....", - ".........", + ".RR..RR.", + "RBBRRRRR", + "RBRRRRRR", + "RRRRRRRR", + ".RRRRRR.", + "..RRRR..", + "...RR...", ] # Un « dictionnaire » associe chaque lettre à une couleur. @@ -114,20 +114,32 @@ COULEUR_DE_LETTRE = { } -def dessiner_dessin(depart_x): +def dessiner_dessin(depart_x, depart_y): for y, ligne in enumerate(DESSIN): # enumerate donne le numéro ET la ligne for x, lettre in enumerate(ligne): if lettre in COULEUR_DE_LETTRE: - pixel(depart_x + x, y, COULEUR_DE_LETTRE[lettre]) + pixel(depart_x + x, depart_y + y, COULEUR_DE_LETTRE[lettre]) def anim_dessin_qui_defile(): largeur_dessin = len(DESSIN[0]) # len = longueur de la ligne - for x in range(LARGEUR, -largeur_dessin - 1, -1): - effacer() - dessiner_dessin(x) - montrer() - attendre(70) + hauteur_dessin = len(DESSIN) # = nombre de lignes + if HAUTEUR > LARGEUR: + # Matrice DEBOUT : le dessin monte du bas vers le haut. + x = (LARGEUR - largeur_dessin) // 2 # centré + for y in range(HAUTEUR, -hauteur_dessin - 1, -1): + effacer() + dessiner_dessin(x, y) + montrer() + attendre(70) + else: + # Matrice COUCHÉE : le dessin passe de droite à gauche. + y = (HAUTEUR - hauteur_dessin) // 2 + for x in range(LARGEUR, -largeur_dessin - 1, -1): + effacer() + dessiner_dessin(x, y) + montrer() + attendre(70) # --- Animation 4 : étincelles --- @@ -144,8 +156,8 @@ def anim_etincelles(): def anim_ma_premiere(): for x in range(LARGEUR): effacer() - pixel(x, 3, JAUNE) - pixel(x, 4, JAUNE) + pixel(x, HAUTEUR // 2 - 1, JAUNE) # deux points au milieu de la hauteur + pixel(x, HAUTEUR // 2, JAUNE) montrer() attendre(50) diff --git a/8x32 PI/outils/generer_exemples.py b/8x32 PI/outils/generer_exemples.py index 7c9d878..3fa7509 100755 --- a/8x32 PI/outils/generer_exemples.py +++ b/8x32 PI/outils/generer_exemples.py @@ -25,12 +25,15 @@ Pillow, puis les jouer comme des dessins. import math import os +import sys from PIL import Image ICI = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ANIMATIONS = os.path.join(ICI, "animations") -L, H = 32, 8 +sys.path.insert(0, ICI) +from matrice import LARGEUR as L, HAUTEUR as H # noqa: E402 (8 × 32 si debout) +DEBOUT = H > L def nouvelle_image(): @@ -56,14 +59,13 @@ PETIT_COEUR = [ "...R...", ] GRAND_COEUR = [ - ".RRR.RRR.", - "RBBRRRRRR", - "RBRRRRRRR", - "RRRRRRRRR", - ".RRRRRRR.", - "..RRRRR..", - "...RRR...", - "....R....", + ".RR..RR.", + "RBBRRRRR", + "RBRRRRRR", + "RRRRRRRR", + ".RRRRRR.", + "..RRRR..", + "...RR...", ] @@ -75,7 +77,7 @@ def coeur_qui_bat(): (GRAND_COEUR, 160)]: im = nouvelle_image() largeur = len(motif[0]) - dessiner(im, motif, (L - largeur) // 2, (H - len(motif)) // 2 + (len(motif) == 6), couleurs) + dessiner(im, motif, (L - largeur) // 2, (H - len(motif)) // 2, couleurs) images.append(im) durees.append(duree) images[0].save(os.path.join(ANIMATIONS, "01_coeur_qui_bat.gif"), save_all=True, @@ -103,26 +105,43 @@ POLICE = { } -def texte_defilant(texte="VITRINE PIXELS"): - # 1) On écrit tout le texte sur une longue bande. - largeur_totale = sum(len(POLICE[c][0]) + 1 for c in texte) - bande = Image.new("RGB", (largeur_totale, H)) - x = 0 - for i, c in enumerate(texte): - # chaque lettre a sa propre teinte : un petit arc-en-ciel - r, v, b = [int(255 * (0.5 + 0.5 * math.cos(2 * math.pi * (i / len(texte) + k / 3)))) - for k in range(3)] - for y, ligne in enumerate(POLICE[c]): - for dx, p in enumerate(ligne): - if p == "#": - bande.putpixel((x + dx, y), (r, v, b)) - x += len(POLICE[c][0]) + 1 +def couleur_de_lettre(i, n): + """Chaque lettre a sa propre teinte : un petit arc-en-ciel.""" + return tuple(int(255 * (0.5 + 0.5 * math.cos(2 * math.pi * (i / n + k / 3)))) + for k in range(3)) - # 2) On fait glisser une « fenêtre » de 32 pixels sur la bande. + +def texte_defilant(texte="VITRINE PIXELS"): + # 1) On écrit tout le texte sur une longue bande : + # - matrice couchée : les lettres côte à côte, la bande défile vers la gauche ; + # - matrice debout : les lettres les unes SOUS les autres (comme une + # enseigne verticale), la bande défile vers le haut. + if DEBOUT: + bande = Image.new("RGB", (L, len(texte) * 8)) + for i, c in enumerate(texte): + x0 = (L - len(POLICE[c][0])) // 2 # lettre centrée + for y, ligne in enumerate(POLICE[c]): + for dx, p in enumerate(ligne): + if p == "#": + bande.putpixel((x0 + dx, i * 8 + y), couleur_de_lettre(i, len(texte))) + longueur, fenetre = bande.size[1], H + else: + longueur = sum(len(POLICE[c][0]) + 1 for c in texte) + bande = Image.new("RGB", (longueur, H)) + x = 0 + for i, c in enumerate(texte): + for y, ligne in enumerate(POLICE[c]): + for dx, p in enumerate(ligne): + if p == "#": + bande.putpixel((x + dx, y), couleur_de_lettre(i, len(texte))) + x += len(POLICE[c][0]) + 1 + fenetre = L + + # 2) On fait glisser une « fenêtre » de la taille de la matrice sur la bande. images = [] - for decalage in range(-L, largeur_totale + 1): + for decalage in range(-fenetre, longueur + 1): im = nouvelle_image() - im.paste(bande, (-decalage, 0)) + im.paste(bande, (0, -decalage) if DEBOUT else (-decalage, 0)) images.append(im) images[0].save(os.path.join(ANIMATIONS, "02_texte_defilant.gif"), save_all=True, append_images=images[1:], duration=60, loop=0) @@ -137,7 +156,9 @@ def vague(nb_images=40, ips=20): im = nouvelle_image() phase = 2 * math.pi * n / nb_images for x in range(L): - hauteur = (H - 1) / 2 * (1 + math.sin(2 * math.pi * x / L + phase)) + # Hauteur de l'eau dans la colonne x : la moitié de la matrice, + # plus ou moins une vague qui se déplace avec « phase ». + hauteur = H / 2 + max(1.5, H * 0.15) * math.sin(2 * math.pi * x / max(L, 16) + phase) for y in range(H): if H - 1 - y <= hauteur: # sous la crête de la vague # 3 bleus seulement, du plus clair (crête) au plus profond : diff --git a/8x32 PI/outils/schemas.py b/8x32 PI/outils/schemas.py index a63a7b2..853af18 100755 --- a/8x32 PI/outils/schemas.py +++ b/8x32 PI/outils/schemas.py @@ -416,7 +416,7 @@ def brochage(): def chaine(): s = Svg(1200, 330, "De Krita à la matrice : le trajet d'une animation") etapes = [ - ("#7b3fa0", "1. Dessiner", ["Krita", "document 32 × 8 px", "pixel art, sans flou"]), + ("#7b3fa0", "1. Dessiner", ["Krita", "document 8 × 32 px", "(vertical), sans flou"]), ("#b5418f", "2. Exporter", ["GIF animé", "ou séquence de PNG", "(Rendu de l'animation)"]), ("#c9682a", "3. Vérifier", ["verifier_animation.py", "+ simulateur", "sur l'ordinateur"]), ("#b08900", "4. Déposer", ["dossier « animations »", "carte SD ou réseau", "(01_, 02_… = ordre)"]), diff --git a/8x32 PI/outils/verifier_animation.py b/8x32 PI/outils/verifier_animation.py index 9dbd4fb..5314ec7 100755 --- a/8x32 PI/outils/verifier_animation.py +++ b/8x32 PI/outils/verifier_animation.py @@ -39,7 +39,7 @@ from matrice import LARGEUR, HAUTEUR, TABLE_GAMMA, LUMINOSITE, courant_estime_ma DOSSIER_APERCUS = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "apercus") TAILLE_LED = 14 # taille d'un pixel dans l'aperçu, en points -MAX_PLANCHE = 48 # nombre maximum d'images sur la planche +MAX_PLANCHE = 24 # nombre maximum d'images sur la planche def pixel_eteint_sur_matrice(r, v, b): @@ -137,14 +137,22 @@ def verifier(chemin): base = os.path.join(DOSSIER_APERCUS, os.path.splitext(nom)[0] + "_apercu") images = [dessiner_leds(image) for image, _ in etapes] - # Planche : les images les unes sous les autres, numérotées. + # Planche : toutes les images, numérotées. Matrice couchée : les unes sous + # les autres ; matrice debout : côte à côte, comme une bande dessinée. montrees = images[:MAX_PLANCHE] l, h = images[0].size - planche = Image.new("RGB", (l + 50, len(montrees) * (h + 6)), (255, 255, 255)) - crayon = ImageDraw.Draw(planche) - for i, im in enumerate(montrees): - planche.paste(im, (50, i * (h + 6))) - crayon.text((6, i * (h + 6) + h // 2 - 6), str(i + 1), fill=(0, 0, 0)) + if h > l: + planche = Image.new("RGB", (len(montrees) * (l + 8), h + 24), (255, 255, 255)) + crayon = ImageDraw.Draw(planche) + for i, im in enumerate(montrees): + planche.paste(im, (i * (l + 8), 24)) + crayon.text((i * (l + 8) + l // 2 - 6, 6), str(i + 1), fill=(0, 0, 0)) + else: + planche = Image.new("RGB", (l + 50, len(montrees) * (h + 6)), (255, 255, 255)) + crayon = ImageDraw.Draw(planche) + for i, im in enumerate(montrees): + planche.paste(im, (50, i * (h + 6))) + crayon.text((6, i * (h + 6) + h // 2 - 6), str(i + 1), fill=(0, 0, 0)) planche.save(base + ".png") print(f" Aperçu : {base}.png", end="") diff --git a/8x32 PI/schemas/chaine_krita_matrice.svg b/8x32 PI/schemas/chaine_krita_matrice.svg index 445c12d..58b444c 100644 --- a/8x32 PI/schemas/chaine_krita_matrice.svg +++ b/8x32 PI/schemas/chaine_krita_matrice.svg @@ -7,8 +7,8 @@ 1. Dessiner Krita - document 32 × 8 px - pixel art, sans flou + document 8 × 32 px + (vertical), sans flou diff --git a/8x32 PI/serveur_web.py b/8x32 PI/serveur_web.py new file mode 100644 index 0000000..4c28b7e --- /dev/null +++ b/8x32 PI/serveur_web.py @@ -0,0 +1,527 @@ +""" +============================================================================= + SERVEUR WEB — gérer les animations depuis un navigateur + Workshop « Vitrine pixels » +============================================================================= + +Ce programme transforme le Raspberry Pi en petit SITE WEB. Depuis +n'importe quel ordinateur ou téléphone connecté au même réseau, on ouvre +l'adresse du Pi dans un navigateur (par exemple http://172.16.50.27 en +Wi-Fi ; voir INTERFACE_WEB.md) et on peut : + + - DÉPOSER des animations (GIF, PNG animés, séquences de PNG…) ; + - voir un APERÇU animé de chacune ; + - choisir celles qui sont JOUÉES (activer / désactiver) et leur ORDRE ; + - en jouer une TOUT DE SUITE, ou passer à la suivante ; + - les SUPPRIMER (elles passent d'abord par une corbeille). + +Il ne parle pas lui-même à la matrice : c'est toujours lecteur.py qui joue +les animations. Le serveur se contente de ranger les fichiers dans le +dossier des animations, et de laisser des « post-it » au lecteur +(voir lecteur.py, partie 5). + + +COMMENT LE LANCER +----------------- +Sur le Raspberry Pi (normalement, c'est le service « vitrine-web » qui le +lance tout seul, voir MODE_VITRINE.md) : + sudo ~/vitrine-env/bin/python serveur_web.py + +Sur votre ordinateur, pour essayer (avec le lecteur en simulateur à côté) : + python3 serveur_web.py --port 8080 +puis ouvrir http://localhost:8080 dans le navigateur. + + +LES OUTILS UTILISÉS +------------------- + - Flask : une bibliothèque Python pour fabriquer des sites web. Chaque + « route » (@app.route) associe une adresse (/, /ajouter…) à une fonction + Python qui fabrique la réponse. + - Jinja : le système de « gabarits » de Flask. La page web est écrite en + HTML dans web/page.html, avec des trous {{ … }} que Python remplit. + - Pillow : pour vérifier les images reçues et fabriquer les aperçus. + + +ORGANISATION DU PROGRAMME +------------------------- + 1. Réglages. + 2. Les noms de fichiers. + 3. Lister les animations et leurs informations. + 4. Ranger : ordre, activer / désactiver, corbeille. + 5. Les aperçus animés. + 6. Les pages et les actions (les « routes »). + 7. Le programme principal. +""" + +import argparse +import io +import os +import re +import shutil +import socket +import subprocess +import tempfile +import unicodedata + +from flask import (Flask, Response, abort, flash, jsonify, redirect, + render_template, request, send_file, url_for) +from PIL import Image + +import lecteur +from matrice import LARGEUR, HAUTEUR + + +# ============================================================================= +# 1. RÉGLAGES +# ============================================================================= + +# Port du serveur. 80 est le port « normal » du web : on tape juste +# http://172.16.50.27 dans le navigateur. (Il faut être administrateur.) +PORT = 80 + +# Mot de passe pour accéder à la page. None = pas de mot de passe : +# toute personne sur le même réseau peut ajouter ou supprimer des animations. +# Exemple : MOT_DE_PASSE = "pixels" (le navigateur demandera un nom +# d'utilisateur, n'importe lequel, et ce mot de passe). +MOT_DE_PASSE = None + +# Taille maximum d'un envoi, en mégaoctets. +TAILLE_MAX_MO = 50 + +# Sous-dossiers spéciaux du dossier des animations. Leur nom commence par +# un point : le lecteur les ignore (voir lecteur.est_cache). +DESACTIVEES = ".desactivees" +CORBEILLE = ".corbeille" + +ICI = os.path.dirname(os.path.abspath(__file__)) + +app = Flask(__name__, + template_folder=os.path.join(ICI, "web"), # où est page.html + static_folder=os.path.join(ICI, "web"), # où est style.css + static_url_path="/static") +app.secret_key = os.urandom(16) # nécessaire pour les messages « flash » +app.config["MAX_CONTENT_LENGTH"] = TAILLE_MAX_MO * 1024 * 1024 + +DOSSIER = lecteur.trouver_dossier() # peut être changé par --dossier + + +# ============================================================================= +# 2. LES NOMS DE FICHIERS +# ============================================================================= + +def nettoyer_nom(nom): + """« Pluie de cœurs (v2)!! » → « pluie_de_coeurs_v2 ». + Sans accent, sans espace, sans caractère spécial : des noms qui ne posent + de problème à aucun ordinateur (et pas de « ../ » malicieux).""" + nom = unicodedata.normalize("NFKD", nom) + nom = nom.replace("œ", "oe").replace("Œ", "oe").replace("æ", "ae") + nom = nom.encode("ascii", "ignore").decode("ascii").lower() # enlève les accents + nom = re.sub(r"[^a-z0-9]+", "_", nom).strip("_") + return nom or "animation" + + +def sans_numero(nom): + """« 03_vague » → « vague » : on retire le numéro d'ordre du début.""" + return re.sub(r"^\d+[_-]?", "", nom) + + +def decouper(nom): + """« 03_vague.gif » → (« 03_vague », « .gif ») ; un dossier n'a pas d'extension.""" + base, extension = os.path.splitext(nom) + if extension.lower() not in lecteur.EXTENSIONS: + return nom, "" + return base, extension.lower() + + +def chemin_sur(relatif): + """Transforme un nom reçu du navigateur en chemin, en vérifiant qu'il reste + DANS le dossier des animations. Sinon, quelqu'un pourrait demander + « ../../etc/passwd »… On refuse tout ce qui sort du dossier.""" + chemin = os.path.realpath(os.path.join(DOSSIER, relatif)) + racine = os.path.realpath(DOSSIER) + if not chemin.startswith(racine + os.sep) or not os.path.exists(chemin): + abort(404) + return chemin + + +def nom_libre(dossier, nom): + """Si « vague.gif » existe déjà dans le dossier, propose « vague_2.gif »…""" + base, extension = decouper(nom) + candidat, n = nom, 2 + while os.path.exists(os.path.join(dossier, candidat)): + candidat = f"{base}_{n}{extension}" + n += 1 + return candidat + + +# ============================================================================= +# 3. LISTER LES ANIMATIONS ET LEURS INFORMATIONS +# ============================================================================= + +def actives(): + """Noms des animations jouées, dans l'ordre de lecture.""" + return [os.path.basename(c) for c in lecteur.lister_animations(DOSSIER)] + + +def desactivees(): + return [os.path.basename(c) + for c in lecteur.lister_animations(os.path.join(DOSSIER, DESACTIVEES))] + + +def date_de_modif(chemin): + """Date de dernière modification (pour savoir si un aperçu est à refaire).""" + if os.path.isdir(chemin): + return max([os.path.getmtime(chemin)] + + [os.path.getmtime(f) for f in lecteur.images_du_dossier(chemin)]) + return os.path.getmtime(chemin) + + +# Clé du cache : le nom SANS numéro + la date, pour qu'un simple changement +# d'ordre (01_vague → 02_vague) ne force pas à tout recalculer. +_cache = {} # clé → (informations, aperçu GIF) + + +def analyser(chemin): + """Charge une animation comme le lecteur, et en tire les informations + affichées sur la page + l'aperçu. Le résultat est gardé en mémoire + (« cache ») tant que le fichier ne change pas : le Pi 3 n'est pas rapide.""" + date = date_de_modif(chemin) + cle = (sans_numero(os.path.basename(chemin)), date) + if cle in _cache: + return _cache[cle] + + # Taille d'origine (avant adaptation à 32 × 8). + premier = lecteur.images_du_dossier(chemin)[0] if os.path.isdir(chemin) else chemin + with Image.open(premier) as image: + taille = image.size + + etapes = lecteur.charger(chemin) + fixe = len(etapes) == 1 + infos = { + "images": len(etapes), + "duree": sum(d for _, d in etapes), + "fixe": fixe, + "sequence": os.path.isdir(chemin), + "taille": taille, + "taille_ok": taille == (LARGEUR, HAUTEUR), + "version": int(date), + } + apercu = fabriquer_apercu(etapes, fixe) + _cache[cle] = (infos, apercu) + return infos, apercu + + +# ============================================================================= +# 4. RANGER : ORDRE, ACTIVER / DÉSACTIVER, CORBEILLE +# ============================================================================= +# +# L'ordre de lecture, c'est l'ordre ALPHABÉTIQUE des noms (voir lecteur.py). +# Pour changer l'ordre, on renomme donc les fichiers : 01_…, 02_…, 03_… + +def renumeroter(noms): + """Renomme les animations actives pour qu'elles portent les numéros + 01, 02, 03… dans l'ordre de la liste « noms ».""" + # 1re étape : noms temporaires (commençant par un point, donc ignorés par + # le lecteur), pour ne jamais écraser un fichier qui a déjà le nom visé. + temporaires = [] + for i, nom in enumerate(noms): + temporaire = f".renommage_{i}_{nom}" + os.rename(os.path.join(DOSSIER, nom), os.path.join(DOSSIER, temporaire)) + temporaires.append(temporaire) + # 2e étape : les noms définitifs. + # Tous les numéros ont le même nombre de chiffres (01…99, ou 001…999 au-delà + # de 99 animations), sinon l'ordre alphabétique mettrait « 100 » avant « 11 ». + chiffres = max(2, len(str(len(noms)))) + for i, (nom, temporaire) in enumerate(zip(noms, temporaires)): + definitif = f"{i + 1:0{chiffres}d}_{sans_numero(nom)}" + os.rename(os.path.join(DOSSIER, temporaire), os.path.join(DOSSIER, definitif)) + + +def ranger_dans(sous_dossier, nom, depuis=None): + """Déplace une animation dans un sous-dossier (désactivées ou corbeille).""" + depuis = depuis or DOSSIER + destination = os.path.join(DOSSIER, sous_dossier) + os.makedirs(destination, exist_ok=True) + nouveau = nom_libre(destination, sans_numero(nom)) + shutil.move(os.path.join(depuis, nom), os.path.join(destination, nouveau)) + + +def ajouter_a_la_fin(chemin_source, nom): + """Place une nouvelle animation à la fin de la liste de lecture.""" + noms = actives() + nom = nom_libre(DOSSIER, f"{len(noms) + 1:02d}_{nom}") + shutil.move(chemin_source, os.path.join(DOSSIER, nom)) + renumeroter(noms + [nom]) + + +# ============================================================================= +# 5. LES APERÇUS ANIMÉS +# ============================================================================= + +# Agrandissement des aperçus : le grand côté fait 192 points +# (32 × 8 → 192 × 48 si la matrice est couchée, 8 × 32 → 48 × 192 si elle est debout). +ZOOM = 192 // max(LARGEUR, HAUTEUR) + + +def fabriquer_apercu(etapes, fixe): + """Fabrique un GIF agrandi, exactement comme la matrice va l'afficher + (après adaptation de la taille). Renvoie les octets du fichier.""" + images, durees = [], [] + for image, duree in etapes: + images.append(image.resize((LARGEUR * ZOOM, HAUTEUR * ZOOM), lecteur.PLUS_PROCHE_VOISIN)) + durees.append(max(20, round(duree * 1000))) + if fixe: + durees = [1000] + sortie = io.BytesIO() # un « fichier » en mémoire, sans toucher au disque + images[0].save(sortie, format="GIF", save_all=True, append_images=images[1:], + duration=durees, loop=0) + return sortie.getvalue() + + +# ============================================================================= +# 6. LES PAGES ET LES ACTIONS (LES « ROUTES ») +# ============================================================================= + +@app.before_request +def verifier_mot_de_passe(): + """Avant chaque page : si un mot de passe est réglé, on le demande.""" + if MOT_DE_PASSE is None: + return None + auth = request.authorization + if auth is None or auth.password != MOT_DE_PASSE: + return Response("Mot de passe nécessaire.", 401, + {"WWW-Authenticate": 'Basic realm="Vitrine pixels"'}) + return None + + +def lire_en_cours(): + try: + with open(lecteur.FICHIER_EN_COURS) as f: + return f.read().strip() + except OSError: + return "" + + +def adresses_du_pi(): + """Les adresses du Pi sur ses réseaux (câble, Wi-Fi), pour les afficher + en bas de la page : elles peuvent changer d'un jour à l'autre.""" + try: + sortie = subprocess.run(["hostname", "-I"], capture_output=True, text=True, + timeout=2).stdout + adresses = [a for a in sortie.split() if "." in a] # IPv4 seulement + except (OSError, subprocess.SubprocessError): + adresses = [] + return adresses or [socket.gethostname()] + + +def fiche(nom, sous_dossier=""): + """Tout ce que la page doit savoir sur une animation.""" + relatif = os.path.join(sous_dossier, nom) if sous_dossier else nom + chemin = os.path.join(DOSSIER, relatif) + try: + infos, _ = analyser(chemin) + erreur = None + except Exception as e: # fichier abîmé : on l'affiche quand même, avec l'erreur + infos, erreur = {"version": 0}, str(e) + return {"nom": nom, "titre": sans_numero(decouper(nom)[0]).replace("_", " "), + "relatif": relatif, "infos": infos, "erreur": erreur} + + +@app.route("/") +def accueil(): + """La page principale.""" + corbeille = os.path.join(DOSSIER, CORBEILLE) + nb_corbeille = len(os.listdir(corbeille)) if os.path.isdir(corbeille) else 0 + return render_template( + "page.html", + actives=[fiche(n) for n in actives()], + desactivees=[fiche(n, DESACTIVEES) for n in desactivees()], + en_cours=lire_en_cours(), + nb_corbeille=nb_corbeille, + dossier=DOSSIER, + adresses=adresses_du_pi(), + port="" if request.host.endswith(":80") or ":" not in request.host + else ":" + request.host.rsplit(":", 1)[1], + extensions=",".join(lecteur.EXTENSIONS), + taille_max=TAILLE_MAX_MO, + largeur=LARGEUR, hauteur=HAUTEUR, + ) + + +@app.route("/apercu/") +def apercu(relatif): + """L'aperçu GIF d'une animation (affiché dans la page par une balise ).""" + try: + _, octets = analyser(chemin_sur(relatif)) + except Exception: + abort(404) + reponse = send_file(io.BytesIO(octets), mimetype="image/gif") + reponse.headers["Cache-Control"] = "max-age=86400" # l'URL change avec la version + return reponse + + +@app.route("/etat") +def etat(): + """Ce que joue le lecteur en ce moment (la page le redemande toutes les 2 s).""" + return jsonify(en_cours=lire_en_cours()) + + +def verifier_animation(chemin): + """Vérifie qu'une animation se charge vraiment, comme le ferait le lecteur. + Renvoie un message d'erreur, ou None si tout va bien.""" + try: + if not lecteur.charger(chemin): + return "aucune image trouvée" + except Exception as erreur: + return f"fichier illisible ({erreur})" + return None + + +@app.route("/ajouter", methods=["POST"]) +def ajouter(): + """Reçoit un ou plusieurs fichiers : chacun devient une animation.""" + fichiers = [f for f in request.files.getlist("fichiers") if f.filename] + if not fichiers: + flash("Aucun fichier choisi.", "erreur") + for fichier in fichiers: + base, extension = os.path.splitext(fichier.filename) + extension = extension.lower() + if extension not in lecteur.EXTENSIONS: + flash(f"« {fichier.filename} » : format non accepté " + f"(acceptés : {', '.join(lecteur.EXTENSIONS)}).", "erreur") + continue + # On enregistre d'abord dans un dossier temporaire, on vérifie, puis on range. + descripteur, temporaire = tempfile.mkstemp(suffix=extension, dir=lecteur.DOSSIER_ETAT) + os.close(descripteur) + fichier.save(temporaire) + probleme = verifier_animation(temporaire) + if probleme: + os.remove(temporaire) + flash(f"« {fichier.filename} » refusé : {probleme}.", "erreur") + continue + ajouter_a_la_fin(temporaire, nettoyer_nom(sans_numero(base)) + extension) + flash(f"« {fichier.filename} » ajouté à la fin de la liste.", "ok") + return redirect(url_for("accueil")) + + +@app.route("/ajouter_sequence", methods=["POST"]) +def ajouter_sequence(): + """Reçoit plusieurs PNG qui forment UNE animation (export Krita en séquence).""" + fichiers = sorted((f for f in request.files.getlist("images") if f.filename), + key=lambda f: f.filename) # ordre des noms = ordre des images + nom = nettoyer_nom(request.form.get("nom", "") or "sequence") + try: + ips = float(request.form.get("ips", "12").replace(",", ".")) + assert 0.5 <= ips <= 60 + except (ValueError, AssertionError): + flash("Images par seconde : un nombre entre 0,5 et 60.", "erreur") + return redirect(url_for("accueil")) + if len(fichiers) < 2: + flash("Une séquence demande au moins 2 images PNG.", "erreur") + return redirect(url_for("accueil")) + + temporaire = tempfile.mkdtemp(dir=lecteur.DOSSIER_ETAT) + for i, fichier in enumerate(fichiers): + if not fichier.filename.lower().endswith(".png"): + shutil.rmtree(temporaire) + flash(f"« {fichier.filename} » n'est pas un PNG.", "erreur") + return redirect(url_for("accueil")) + fichier.save(os.path.join(temporaire, f"{nom}_{i:04d}.png")) + with open(os.path.join(temporaire, "images_par_seconde.txt"), "w") as f: + f.write(f"{ips:g}\n") + probleme = verifier_animation(temporaire) + if probleme: + shutil.rmtree(temporaire) + flash(f"Séquence refusée : {probleme}.", "erreur") + else: + ajouter_a_la_fin(temporaire, nom) + flash(f"Séquence « {nom} » ajoutée ({len(fichiers)} images, {ips:g} i/s).", "ok") + return redirect(url_for("accueil")) + + +@app.route("/action", methods=["POST"]) +def action(): + """Tous les boutons des fiches : monter, descendre, jouer, désactiver…""" + quoi = request.form.get("action") + nom = os.path.basename(request.form.get("nom", "")) # basename : pas de « ../ » + noms = actives() + + if quoi == "suivante": + lecteur.ecrire_post_it(lecteur.FICHIER_DEMANDE, lecteur.SUIVANTE) + flash("On passe à l'animation suivante.", "ok") + + elif quoi in ("monter", "descendre") and nom in noms: + i = noms.index(nom) + j = i - 1 if quoi == "monter" else i + 1 + if 0 <= j < len(noms): + noms[i], noms[j] = noms[j], noms[i] # on échange les deux places + renumeroter(noms) + + elif quoi == "jouer": + relatif = request.form.get("relatif", "") + chemin_sur(relatif) # vérifie que l'animation existe bien + lecteur.ecrire_post_it(lecteur.FICHIER_DEMANDE, relatif) + flash(f"« {nom} » va être jouée tout de suite.", "ok") + + elif quoi == "desactiver" and nom in noms: + ranger_dans(DESACTIVEES, nom) + noms.remove(nom) + renumeroter(noms) + flash(f"« {nom} » ne sera plus jouée.", "ok") + + elif quoi == "activer" and nom in desactivees(): + ajouter_a_la_fin(os.path.join(DOSSIER, DESACTIVEES, nom), nom) + flash(f"« {nom} » est ajoutée à la fin de la liste.", "ok") + + elif quoi == "supprimer": + if nom in noms: + ranger_dans(CORBEILLE, nom) + noms.remove(nom) + renumeroter(noms) + elif nom in desactivees(): + ranger_dans(CORBEILLE, nom, depuis=os.path.join(DOSSIER, DESACTIVEES)) + flash(f"« {nom} » est dans la corbeille.", "ok") + + elif quoi == "vider_corbeille": + shutil.rmtree(os.path.join(DOSSIER, CORBEILLE), ignore_errors=True) + flash("Corbeille vidée.", "ok") + + return redirect(url_for("accueil") + request.form.get("ancre", "")) + + +@app.errorhandler(413) +def trop_gros(erreur): + flash(f"Envoi trop gros (maximum {TAILLE_MAX_MO} Mo).", "erreur") + return redirect(url_for("accueil")) + + +@app.errorhandler(OSError) +def erreur_disque(erreur): + # Cas typique : partition protégée en écriture (overlay, voir MODE_VITRINE.md). + flash(f"Impossible d'écrire dans le dossier des animations ({erreur}). " + "La carte SD est-elle protégée en écriture ?", "erreur") + return redirect(url_for("accueil")) + + +# ============================================================================= +# 7. LE PROGRAMME PRINCIPAL +# ============================================================================= + +def main(): + global DOSSIER + options = argparse.ArgumentParser(description="Interface web de la vitrine pixels.") + options.add_argument("--port", type=int, default=PORT) + options.add_argument("--dossier", help="dossier d'animations à gérer") + args = options.parse_args() + + if args.dossier: + DOSSIER = os.path.abspath(args.dossier) + os.makedirs(DOSSIER, exist_ok=True) + print("Dossier des animations :", DOSSIER) + print(f"Ouvrez http://:{args.port} dans un navigateur.") + # host="0.0.0.0" : accepter les visites venant des autres ordinateurs du réseau. + app.run(host="0.0.0.0", port=args.port, threaded=True) + + +if __name__ == "__main__": + main() diff --git a/8x32 PI/test_matrice.py b/8x32 PI/test_matrice.py index d0d7edc..844f775 100644 --- a/8x32 PI/test_matrice.py +++ b/8x32 PI/test_matrice.py @@ -15,7 +15,8 @@ Trois tests s'enchaînent, puis tout s'éteint : 1. LES COINS : un pixel de couleur dans chaque coin, 5 secondes. ROUGE en haut à gauche VERT en haut à droite BLEU en bas à gauche BLANC en bas à droite - Sinon : changez INVERSER_X / INVERSER_Y dans matrice.py. + (la matrice étant posée comme dans la vitrine : debout si ROTATION = 90) + Sinon : changez INVERSER_X / INVERSER_Y (ou ROTATION) dans matrice.py. 2. L'ORDRE DE LA CHAÎNE : un point orange parcourt les 256 LED dans l'ordre où le signal les traverse. On voit le serpentin. diff --git a/8x32 PI/web/infos.html b/8x32 PI/web/infos.html new file mode 100644 index 0000000..9083267 --- /dev/null +++ b/8x32 PI/web/infos.html @@ -0,0 +1,12 @@ +{# Petit morceau de page, inclus dans chaque fiche : les informations d'une animation « a ». #} +{% if a.erreur %} + ⚠ fichier illisible : {{ a.erreur }} +{% else %} + + {% if a.infos.fixe %}image fixe{% else %}{{ a.infos.images }} images · {{ ("%.1f"|format(a.infos.duree)).replace(".", ",") }} s{% endif %} + {% if a.infos.sequence %}· séquence PNG{% endif %} + + {% if not a.infos.taille_ok %} + ⚠ {{ a.infos.taille[0] }} × {{ a.infos.taille[1] }} px au lieu de {{ largeur }} × {{ hauteur }} + {% endif %} +{% endif %} diff --git a/8x32 PI/web/page.html b/8x32 PI/web/page.html new file mode 100644 index 0000000..de3f2d4 --- /dev/null +++ b/8x32 PI/web/page.html @@ -0,0 +1,208 @@ + +{# + La page web de la vitrine. C'est un « gabarit » Jinja : Python (serveur_web.py) + remplace les doubles accolades par des valeurs, et répète les blocs « for ». + (Ce commentaire-ci, entre accolades-dièses, n'est pas envoyé au navigateur.) +#} + + + + + Vitrine pixels + + + + +
+

Vitrine pixels

+
+ + En ce moment : {{ en_cours or "—" }} +
+ + +
+
+
+ +
+ + {# Les messages « flash » : résultat de la dernière action #} + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for categorie, message in messages %} +

{{ message }}

+ {% endfor %} +
+ {% endif %} + {% endwith %} + + +
+

Ajouter des animations

+
+ +
+

Un ou plusieurs fichiers

+

GIF animé, PNG animé, WebP ou image fixe, exportés de Krita en + {{ largeur }} × {{ hauteur }} pixels. Chaque fichier devient une animation.

+ + +
+ +
+

Une séquence de PNG

+

Toutes les images d'un export Krita « séquence d'images » + (feu_0000.png, feu_0001.png…), pour une seule animation.

+ +
+ + +
+ +
+ +
+

Maximum {{ taille_max }} Mo par envoi. Les nouvelles animations + se placent à la fin de la liste.

+
+ + +
+

Jouées, dans cet ordre {{ actives|length }}

+ {% if not actives %} +

Aucune animation active : la matrice affiche un petit point d'attente.

+ {% endif %} +
    + {% for a in actives %} +
  1. + {{ loop.index }} + Aperçu de {{ a.titre }} +
    + {{ a.titre }} + {% include "infos.html" %} +
    +
    + {% set ancre = "#a-" ~ loop.index %} + {% for quoi, signe, titre, actif in [ + ("monter", "▲", "Monter", not loop.first), + ("descendre", "▼", "Descendre", not loop.last), + ("jouer", "▶", "Jouer maintenant", true), + ("desactiver", "⏸", "Ne plus jouer", true), + ("supprimer", "✕", "Supprimer", true)] %} +
    + + + + + +
    + {% endfor %} +
    +
  2. + {% endfor %} +
+
+ + +
+

Pas jouées {{ desactivees|length }}

+ {% if not desactivees %} +

Rien ici. Le bouton ⏸ d'une animation la range ici sans la supprimer.

+ {% endif %} +
    + {% for a in desactivees %} +
  • + – + Aperçu de {{ a.titre }} +
    + {{ a.titre }} + {% include "infos.html" %} +
    +
    + {% for quoi, signe, titre in [("activer", "+", "Remettre dans la liste"), + ("jouer", "▶", "Jouer maintenant (une fois)"), + ("supprimer", "✕", "Supprimer")] %} +
    + + + + + +
    + {% endfor %} +
    +
  • + {% endfor %} +
+
+ +
+ +
+
+ Corbeille : {{ nb_corbeille }} élément(s) + + +
+

Adresses de cette page : + {% for a in adresses %}http://{{ a }}{{ port }}{% if not loop.last %} · {% endif %}{% endfor %} +
(celle qui commence comme l'adresse de votre appareil marche depuis votre réseau)

+

Dossier : {{ dossier }}

+
+ + + + diff --git a/8x32 PI/web/style.css b/8x32 PI/web/style.css new file mode 100644 index 0000000..d9e913a --- /dev/null +++ b/8x32 PI/web/style.css @@ -0,0 +1,172 @@ +/* ============================================================================= + Feuille de style de l'interface web « Vitrine pixels » + Les couleurs sont rangées dans des « variables » (--fond, --texte…) : + on les redéfinit pour le mode sombre, sans toucher au reste. + ============================================================================= */ + +:root { + --fond: #f4f1ea; + --carte: #ffffff; + --texte: #1d1b18; + --discret: #6b665d; + --bord: #ddd6c8; + --accent: #e8462b; /* le rouge-orangé d'une LED allumée */ + --accent-texte: #ffffff; + --vert: #1f8a4c; + --alerte: #b3261e; + --ecran: #101010; /* le noir de la matrice */ + --rayon: 12px; + color-scheme: light; +} + +@media (prefers-color-scheme: dark) { + :root { + --fond: #141312; + --carte: #1f1d1b; + --texte: #f1ede6; + --discret: #a39d92; + --bord: #36322d; + --accent: #ff5a3c; + --vert: #4cc283; + --alerte: #ff8a80; + color-scheme: dark; + } +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--fond); + color: var(--texte); + font: 16px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; +} + +code { font-size: 0.9em; } + +/* ---------- en-tête ---------- */ +header { + position: sticky; top: 0; z-index: 10; + display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; + gap: 8px 24px; + padding: 12px 20px; + background: var(--ecran); + color: #f1ede6; +} +h1 { margin: 0; font-size: 1.3rem; display: flex; align-items: center; gap: 10px; } +.logo { + width: 32px; height: 8px; + background: + radial-gradient(circle, var(--accent) 1.6px, transparent 2px) 0 0 / 4px 4px; +} +.statut { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +.statut form { margin: 0; } +.pastille { + width: 10px; height: 10px; border-radius: 50%; + background: #4cc283; box-shadow: 0 0 8px #4cc283; +} + +main { max-width: 980px; margin: 0 auto; padding: 8px 16px 32px; } +section { margin-top: 28px; } +h2 { font-size: 1.15rem; margin: 0 0 12px; display: flex; align-items: center; gap: 8px; } +h3 { margin: 0 0 6px; font-size: 1rem; } +.compte { + font-size: 0.8rem; font-weight: 600; + background: var(--bord); border-radius: 99px; padding: 1px 9px; +} +.aide, .vide, footer { color: var(--discret); font-size: 0.9rem; } + +/* ---------- messages ---------- */ +/* En bas de l'écran, toujours visibles même si la page a défilé. */ +.messages { + position: fixed; z-index: 20; left: 50%; bottom: 16px; transform: translateX(-50%); + width: min(560px, calc(100% - 32px)); + display: flex; flex-direction: column; gap: 8px; +} +.message { + margin: 0; padding: 10px 14px; + border-radius: var(--rayon); border-left: 5px solid var(--vert); + background: var(--carte); box-shadow: 0 4px 18px rgba(0, 0, 0, 0.35); +} +.message.erreur { border-left-color: var(--alerte); } + +/* ---------- formulaires d'ajout ---------- */ +.deux-colonnes { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; } +.carte { + background: var(--carte); border: 1px solid var(--bord); + border-radius: var(--rayon); padding: 16px; + display: flex; flex-direction: column; gap: 10px; +} +.carte .aide { margin: 0; } +.depot { + display: block; position: relative; cursor: pointer; + border: 2px dashed var(--bord); border-radius: var(--rayon); + padding: 18px; text-align: center; color: var(--discret); +} +.depot:hover { border-color: var(--accent); color: var(--texte); } +.depot input { position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; } +.champs { display: flex; gap: 10px; flex-wrap: wrap; } +.champs label { display: flex; flex-direction: column; font-size: 0.85rem; color: var(--discret); flex: 1; } +.champs input { + margin-top: 4px; padding: 8px; font: inherit; color: var(--texte); + background: var(--fond); border: 1px solid var(--bord); border-radius: 8px; +} + +/* ---------- boutons ---------- */ +.bouton { + font: inherit; cursor: pointer; + padding: 7px 14px; border-radius: 8px; + border: 1px solid var(--bord); background: var(--carte); color: var(--texte); +} +header .bouton { background: #2a2826; border-color: #3d3935; color: #f1ede6; } +.bouton:hover:not(:disabled) { border-color: var(--accent); } +.bouton:disabled { opacity: 0.35; cursor: default; } +.bouton.principal { background: var(--accent); border-color: var(--accent); color: var(--accent-texte); font-weight: 600; } +.bouton.icone { width: 38px; height: 38px; padding: 0; font-size: 1rem; } +.bouton.jouer { color: var(--vert); } +.bouton.supprimer { color: var(--alerte); } + +/* ---------- listes d'animations ---------- */ +.liste { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; } +.fiche { + display: grid; align-items: center; gap: 8px 14px; + grid-template-columns: 28px 256px 1fr auto; + background: var(--carte); border: 1px solid var(--bord); + border-radius: var(--rayon); padding: 10px 12px; + scroll-margin-top: 80px; +} +.fiche.joue { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent); } +.fiche.eteinte .apercu { opacity: 0.55; } +.numero { font-weight: 700; color: var(--discret); text-align: center; } +.apercu { + width: 256px; height: 64px; display: block; + background: var(--ecran); border-radius: 4px; + image-rendering: pixelated; /* garder les pixels nets, pas de flou */ +} +.details { display: flex; flex-direction: column; min-width: 0; } +.details strong { overflow-wrap: anywhere; } +.infos { font-size: 0.85rem; color: var(--discret); } +.alerte { font-size: 0.85rem; color: var(--alerte); } +.boutons { display: flex; gap: 6px; flex-wrap: wrap; } +.boutons form { margin: 0; } + +/* Matrice DEBOUT (8 × 32) : aperçus verticaux, plus étroits. */ +.debout .fiche { grid-template-columns: 28px 48px 1fr auto; } +.debout .apercu { width: 48px; height: 192px; } + +/* Sur téléphone : les boutons passent sous l'aperçu. */ +@media (max-width: 640px) { + .couchee .fiche { grid-template-columns: 24px 1fr; } + .couchee .apercu { width: 100%; height: auto; aspect-ratio: 4 / 1; grid-column: 2; } + .couchee .details, .couchee .boutons { grid-column: 1 / -1; } + .debout .fiche { grid-template-columns: 20px 48px 1fr; } + .debout .boutons { grid-column: 1 / -1; } +} + +footer { + max-width: 980px; margin: 0 auto; padding: 16px; + border-top: 1px solid var(--bord); + display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 10px; +} +footer form { display: flex; align-items: center; gap: 10px; margin: 0; } +footer p { margin: 0; } diff --git a/CLAUDE.md b/CLAUDE.md index 49bddb1..be7f258 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,7 +78,9 @@ Le projet avance en deux temps : - Une équipe design dessine les animations dans **Krita** (32 × 8 px, export séquence PNG ou GIF) ; le Pi les joue telles quelles. - Python : `matrice.py` (moteur : réglages, `XY()`, gamma 2.2, limite de courant, écrans `MatriceReelle` / `Simulateur` tkinter / `Enregistreur` GIF), `lecteur.py` (joue en boucle `/boot/firmware/animations` ou `animations/`), `test_matrice.py`, `mes_animations.py` (animations de l'Arduino traduites). - Matériel : Pi 3B+ / 4 / Zero 2 W (pas le Pi 5 : `rpi_ws281x` ne le gère pas), GPIO 18 (patte 12) → **74AHCT125** (3,3 V → 5 V, VCC pris sur le fil rouge de la fiche DIN) → 330 Ω → DIN. Audio désactivé (`dtparam=audio=off`). -- Installation : `installation/installer.sh` (venv `~/vitrine-env`, service systemd `vitrine-pixels`). Docs : README, INSTALLATION_PI, GUIDE_KRITA, MODE_VITRINE (overlay FS), CODER_EN_PYTHON. +- Installation : `installation/installer.sh` (venv `~/vitrine-env`, services systemd `vitrine-pixels` (lecteur) et `vitrine-web` (interface)). Docs : README, INSTALLATION_PI, GUIDE_KRITA, MODE_VITRINE (overlay FS), INTERFACE_WEB, CODER_EN_PYTHON. +- Interface web `serveur_web.py` (Flask, port 80, gabarits dans `web/`) : dépôt GIF/PNG/séquences, ordre (renommage `01_…`), activer/désactiver (`.desactivees/`), corbeille (`.corbeille/`), « jouer maintenant » via post-it `/run/vitrine-pixels/demande` lu par le lecteur. +- Pi installé : Raspberry Pi 3 Model B, Raspbian 11 Bullseye 32 bits (dossier boot = `/boot`, Pillow 8.1, Flask 1.1), `ssh devel@172.16.7.199` (clé ESADHaR, câble eth0) ; Wi-Fi wlan0 = 172.16.50.27. Le Wi-Fi et le filaire de l'école ne se voient pas : depuis le Wi-Fi, page web sur http://172.16.50.27 (adresses DHCP, affichées en bas de la page). Matrice posée debout : `ROTATION = 90`, image 8 × 32. Pas de 74AHCT125 pendant le workshop : montage A (matrice en 4,4 V) ou B (transistor, `INVERSER_SIGNAL`). - Schémas générés par `outils/schemas.py`, exemples et palette Krita par `outils/generer_exemples.py`. ### Chiffres clés