8x32 PI : interface web, matrice verticale, compatibilité Raspbian 11

- Interface web (serveur_web.py, Flask, gabarits dans web/) : dépôt de GIF,
  PNG animés et séquences de PNG, aperçus animés, ordre de lecture,
  activer / désactiver, corbeille, « jouer maintenant » et « suivante ».
  Service systemd vitrine-web, activé par installer.sh.
- Lecteur : réagit aux demandes de la page web (post-it dans
  /run/vitrine-pixels), écrit l'animation en cours, éteint la matrice
  proprement sur SIGTERM.
- Matrice posée debout : réglage ROTATION (90 = image 8 × 32) dans
  matrice.py ; images couchées tournées automatiquement ; exemples,
  animations codées, aperçus et page web adaptés au format vertical.
- Compatibilité Raspbian 11 Bullseye : partition /boot, Pillow 8.1, Flask 1.1.
- Doc : nouveau guide INTERFACE_WEB.md ; README, GUIDE_KRITA, MODE_VITRINE,
  CODER_EN_PYTHON mis à jour (format 8 × 32, adresses Wi-Fi / câble du Pi).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bach
2026-10-08 16:08:18 +02:00
co-authored by Claude Opus 5.5
parent 98fca33142
commit 2d4b0d5a5f
63 changed files with 1592 additions and 156 deletions
+12 -9
View File
@@ -100,20 +100,23 @@ grâce à la ligne `from matrice import *`.
| Outil | Effet | | 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 | | `lire_pixel(x, y)` | renvoie la couleur actuelle d'un pixel |
| `effacer()` | éteint toute la toile | | `effacer()` | éteint toute la toile |
| `effacer(couleur)` | remplit toute la toile d'une couleur | | `effacer(couleur)` | remplit toute la toile d'une couleur |
| `assombrir(25)` | assombrit toute la toile de 25 % (pour les traînées) | | `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()` | **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 | | `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)` | 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 | | `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`, **Couleurs nommées** : `NOIR`, `BLANC`, `ROUGE`, `ORANGE`, `JAUNE`, `VERT`,
`CYAN`, `BLEU`, `VIOLET`, `MAGENTA`, `ROSE`. `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. 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 from PIL import Image
def anim_logo_qui_defile(): 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") logo = Image.open("animations/logo.png").convert("RGB")
for i in range(100): for i in range(100):
effacer() effacer()
# On « colle » le logo deux fois, côte à côte, décalé de i pixels : # On « colle » le logo deux fois, l'un au-dessus de l'autre, décalé
# ce qui sort à droite rentre à gauche (défilement en boucle). # de i pixels : ce qui sort en bas rentre en haut (défilement en boucle).
toile.paste(logo, (i % LARGEUR - LARGEUR, 0)) toile.paste(logo, (0, i % HAUTEUR - HAUTEUR))
toile.paste(logo, (i % LARGEUR, 0)) toile.paste(logo, (0, i % HAUTEUR))
montrer() montrer()
attendre(60) 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 `effacer()`. Tout ce que sait faire Pillow (coller, retourner, changer les
couleurs, écrire du texte…) fonctionne dessus. Documentation : couleurs, écrire du texte…) fonctionne dessus. Documentation :
<https://pillow.readthedocs.io>. <https://pillow.readthedocs.io>.
+52 -25
View File
@@ -6,7 +6,8 @@ le fichier dans le dossier des animations.
![De Krita à la matrice](schemas/chaine_krita_matrice.svg) ![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 > des couleurs **franches**, un fond **noir**, et un export en **séquence de
> PNG** (ou en GIF). > PNG** (ou en GIF).
@@ -29,26 +30,47 @@ le fichier dans le dossier des animations.
## 1. Comprendre la « toile » ## 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 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 x → 0 . . . . . . 7
y ↓ ┌────────────────────────────────┐ y ↓ ┌─────────────────┐
0 │■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ │ 0 │ ■ ■ ■ ■ ■ ■ ■ ■ │
│ │ 8 lignes │ ■ ■ ■ ■ ■ ■ ■ ■ │
7 │■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ │ │ … │ 32 lignes
└────────────────────────────────┘ │ ■ ■ ■ ■ ■ ■ ■ ■ │
32 colonnes 31 │ ■ ■ ■ ■ ■ ■ ■ ■ │
└─────────────────┘
8 colonnes
``` ```
Quelques ordres de grandeur pour se repérer : Quelques ordres de grandeur pour se repérer :
- un caractère lisible fait **5 pixels de large × 7 de haut** : on écrit - un caractère lisible fait **5 pixels de large × 7 de haut**. Deux façons
**5 lettres** à la fois, pas plus. Un texte plus long doit **défiler** ; d'écrire :
- un visage simple tient dans **8 × 8** ; - lettres **empilées**, les unes sous les autres, comme une enseigne
- une ligne de 1 pixel d'épaisseur se voit très bien : c'est une rangée de LED. 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 > **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 > 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 | | Réglage | Valeur |
|---|---| |---|---|
| Largeur | **32** pixels | | Largeur | **8** pixels |
| Hauteur | **8** pixels | | Hauteur | **32** pixels |
| Résolution | peu importe (72 ppp) | | Résolution | peu importe (72 ppp) |
| Modèle de couleur | **RVB/Alpha**, **8 bits** entier, profil **sRGB** (celui par défaut) | | 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) | | 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 → beaucoup. Si elle n'apparaît pas : **Configuration → Configurer Krita →
Affichage → Grille de pixels**, et baissez le seuil d'affichage. Affichage → Grille de pixels**, et baissez le seuil d'affichage.
> ⚠️ **Ne dessinez pas en grand pour réduire ensuite.** Une image de 1920 × > ⚠️ **Ne dessinez pas en grand pour réduire ensuite.** Une image de 480 ×
> 480 réduite à 32 × 8 devient une bouillie floue. Dessinez **directement à > 1920 réduite à 8 × 32 devient une bouillie floue. Dessinez **directement à
> la taille réelle** : c'est le principe du pixel art. > la taille réelle** : c'est le principe du pixel art.
### Importer la palette « Vitrine pixels » ### Importer la palette « Vitrine pixels »
@@ -196,7 +218,7 @@ supplémentaire.
2. **Dossier** : créez un dossier **au nom de l'animation**, par exemple 2. **Dossier** : créez un dossier **au nom de l'animation**, par exemple
`05_feu_de_camp`. `05_feu_de_camp`.
3. **Nom de base** : `feu` → Krita crée `feu_0000.png`, `feu_0001.png`… 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**. 5. **Rendu**.
6. Dans ce dossier, créez un fichier texte **`images_par_seconde.txt`** qui 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, 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 : 2. Krita a besoin du logiciel gratuit **FFmpeg** pour ça. S'il le demande :
téléchargez-le (<https://ffmpeg.org/download.html>) et indiquez son téléchargez-le (<https://ffmpeg.org/download.html>) et indiquez son
emplacement dans la fenêtre de rendu. 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 > **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 > 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 05_feu_de_camp
Type : séquence de 24 fichiers (12 images/s) 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) Images : 24, durée 2.00 s (≈ 12.0 images/s)
Couleurs : 9 différentes ✓ Couleurs : 9 différentes ✓
Trop sombre : aucune ✓ Trop sombre : aucune ✓
@@ -278,7 +300,7 @@ l'équipe.
| Message | Signification | | 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 | | `⚠ 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) | | `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 | | `⚠ 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 : 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 - **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 l'ordinateur, on copie les animations dans le dossier `animations` du
lecteur nommé **`bootfs`**, on remet la carte. Voir 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. (couleur vive sur noir) se lit de loin.
- **Le mouvement attire l'œil** : une animation lente et régulière se regarde - **Le mouvement attire l'œil** : une animation lente et régulière se regarde
longtemps ; une image fixe passe inaperçue. longtemps ; une image fixe passe inaperçue.
- **Texte** : 5 lettres visibles à la fois. Un défilement d'**1 pixel toutes - **Texte** : 4 lettres empilées (ou 5 couchées) visibles à la fois. Un
les 50 à 80 ms** se lit confortablement. Plus vite, on ne lit plus. 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 - **Boucles** : soignez la jonction fin → début. Un saut se remarque
beaucoup plus sur 256 pixels que sur un écran. beaucoup plus sur 256 pixels que sur un écran.
- **Diffuseur** : si la matrice est placée derrière un matériau diffusant - **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 ## 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 flou : **moins de 64 couleurs** (vérificateur)
- [ ] Pas de couleurs **trop sombres** (vérificateur) - [ ] Pas de couleurs **trop sombres** (vérificateur)
- [ ] Fond **noir** ou transparent - [ ] Fond **noir** ou transparent
+212
View File
@@ -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 <http://localhost:8080>. 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` |
+9
View File
@@ -56,6 +56,7 @@ sudo systemctl disable --now vitrine-pixels
| Je veux… | Commande | | Je veux… | Commande |
|---|---| |---|---|
| savoir si le lecteur tourne | `systemctl status vitrine-pixels` (`active (running)` = oui ; **q** pour quitter) | | 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) | | 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` | | 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` | | 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 dossier est **relu à chaque tour de boucle** : pas besoin de redémarrer
le lecteur après un changement. 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) ### Méthode 1 : la carte SD (sans réseau, sans terminal)
C'est la méthode à donner à l'équipe design. C'est la méthode à donner à l'équipe design.
+24 -9
View File
@@ -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 matrice). Pas besoin de toucher au matériel !
> - **Pour la vitrine** : [MODE_VITRINE.md](MODE_VITRINE.md) (démarrage > - **Pour la vitrine** : [MODE_VITRINE.md](MODE_VITRINE.md) (démarrage
> automatique, changer les animations, coupures de courant). > 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). > - **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 : [`lecteur.py`](lecteur.py), qui :
1. regarde ce qu'il y a dans le dossier `animations` ; 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 3. pour chaque image, **corrige les couleurs** pour que la matrice ressemble
à l'écran de Krita (la « correction gamma »), applique la luminosité et la à l'écran de Krita (la « correction gamma »), applique la luminosité et la
limite de courant ; 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)) : 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 ROUGE ● · · · · · · ● VERT
· · · · · · · · · · · · · · · · · · · · · · · · ·
· · · · · · · · · · · · · · · · · · · · · · · · · (32 lignes)
BLEU ●· · · · · · · · · · · · · · · · ● BLANC · · · · · · · ·
BLEU ● · · · · · · ● BLANC
``` ```
| Ce que vous voyez | Ce qu'il faut faire, dans [`matrice.py`](matrice.py) | | 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 en haut à gauche, vert en haut à droite | rien, c'est parfait ✅ |
| Rouge et vert inversés (gauche ↔ droite) | `INVERSER_X = True` | | Rouge et vert inversés (gauche ↔ droite) | `INVERSER_X = True` |
| Rouge et vert en bas | `INVERSER_Y = 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, Pour modifier le fichier sur le Pi : `nano matrice.py`, changez la ligne,
puis **Ctrl + O**, **Entrée** (enregistrer) et **Ctrl + X** (quitter). 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 | | `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 | | `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) | | `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 | | `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")` | | `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 | | [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 | | [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 | | [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 | | [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 | | [`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 | | [`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 | | [`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 | | [`mes_animations.py`](mes_animations.py) | | les animations de l'Arduino, traduites en Python |
| [`animations/`](animations/) | | exemples : GIF, séquence de PNG, APNG | | [`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/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/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/` | | [`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) |
--- ---
Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 B

After

Width:  |  Height:  |  Size: 299 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 B

After

Width:  |  Height:  |  Size: 149 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 B

After

Width:  |  Height:  |  Size: 151 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 B

After

Width:  |  Height:  |  Size: 147 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 B

After

Width:  |  Height:  |  Size: 155 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 B

After

Width:  |  Height:  |  Size: 155 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 B

After

Width:  |  Height:  |  Size: 156 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 B

After

Width:  |  Height:  |  Size: 154 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 B

After

Width:  |  Height:  |  Size: 154 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 B

After

Width:  |  Height:  |  Size: 152 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 B

After

Width:  |  Height:  |  Size: 153 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 B

After

Width:  |  Height:  |  Size: 152 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 B

After

Width:  |  Height:  |  Size: 152 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 B

After

Width:  |  Height:  |  Size: 138 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 B

After

Width:  |  Height:  |  Size: 144 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 B

After

Width:  |  Height:  |  Size: 139 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 B

After

Width:  |  Height:  |  Size: 135 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 B

After

Width:  |  Height:  |  Size: 135 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 B

After

Width:  |  Height:  |  Size: 139 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 B

After

Width:  |  Height:  |  Size: 141 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 B

After

Width:  |  Height:  |  Size: 144 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 B

After

Width:  |  Height:  |  Size: 145 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 B

After

Width:  |  Height:  |  Size: 148 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 B

After

Width:  |  Height:  |  Size: 145 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 B

After

Width:  |  Height:  |  Size: 135 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 B

After

Width:  |  Height:  |  Size: 144 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 B

After

Width:  |  Height:  |  Size: 141 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 B

After

Width:  |  Height:  |  Size: 154 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 B

After

Width:  |  Height:  |  Size: 146 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 B

After

Width:  |  Height:  |  Size: 142 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 B

After

Width:  |  Height:  |  Size: 149 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 B

After

Width:  |  Height:  |  Size: 150 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 B

After

Width:  |  Height:  |  Size: 153 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 B

After

Width:  |  Height:  |  Size: 145 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 B

After

Width:  |  Height:  |  Size: 147 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 B

After

Width:  |  Height:  |  Size: 143 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 B

After

Width:  |  Height:  |  Size: 138 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 B

After

Width:  |  Height:  |  Size: 139 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 B

After

Width:  |  Height:  |  Size: 151 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 B

After

Width:  |  Height:  |  Size: 144 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 B

After

Width:  |  Height:  |  Size: 154 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

+17 -9
View File
@@ -35,10 +35,10 @@ echo "=== Partition de démarrage : $BOOT"
echo echo
echo "=== 1/5 Installation des logiciels nécessaires (quelques minutes)" echo "=== 1/5 Installation des logiciels nécessaires (quelques minutes)"
# python3-pil = Pillow (lire les images) ; python3-dev et gcc = pour compiler # python3-pil = Pillow (lire les images) ; python3-flask = le serveur web ;
# la bibliothèque rpi_ws281x, qui parle aux LED. # python3-dev et gcc = pour compiler la bibliothèque rpi_ws281x, qui parle aux LED.
sudo apt-get update 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
echo "=== 2/5 Environnement Python dans $ENV" echo "=== 2/5 Environnement Python dans $ENV"
@@ -73,14 +73,22 @@ else
fi fi
echo echo
echo "=== 5/5 Service de démarrage automatique « vitrine-pixels »" echo "=== 5/5 Services de démarrage automatique : lecteur et interface web"
# On recopie le modèle de service en remplaçant @PROJET@ et @PYTHON@ # On recopie chaque modèle de service en remplaçant @PROJET@ et @PYTHON@
# par les vrais chemins. # par les vrais chemins.
sed -e "s|@PROJET@|$PROJET|g" -e "s|@PYTHON@|$ENV/bin/python|g" \ for SERVICE in vitrine-pixels vitrine-web; do
"$PROJET/installation/vitrine-pixels.service" \ sed -e "s|@PROJET@|$PROJET|g" -e "s|@PYTHON@|$ENV/bin/python|g" \
| sudo tee /etc/systemd/system/vitrine-pixels.service > /dev/null "$PROJET/installation/$SERVICE.service" \
| sudo tee "/etc/systemd/system/$SERVICE.service" > /dev/null
done
sudo systemctl daemon-reload 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
echo "================================================================" echo "================================================================"
+31
View File
@@ -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
+150 -22
View File
@@ -42,13 +42,17 @@ ORGANISATION DU PROGRAMME
2. Trouver les animations dans le dossier. 2. Trouver les animations dans le dossier.
3. Charger une animation : la découper en images de 32 × 8. 3. Charger une animation : la découper en images de 32 × 8.
4. Jouer une animation. 4. Jouer une animation.
5. Les horaires (allumer la vitrine seulement à certaines heures). 5. Parler avec le serveur web (serveur_web.py).
6. Le programme principal. 6. Les horaires (allumer la vitrine seulement à certaines heures).
7. Le programme principal.
""" """
import argparse # pour lire les options (--simulateur, --dossier…) import argparse # pour lire les options (--simulateur, --dossier…)
import sys
import datetime # pour connaître l'heure (horaires de la vitrine) import datetime # pour connaître l'heure (horaires de la vitrine)
import os # pour parcourir les dossiers et les fichiers 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 import time
from PIL import Image, ImageSequence from PIL import Image, ImageSequence
@@ -169,7 +173,17 @@ def adapter_image(image, nom):
and largeur // LARGEUR == hauteur // HAUTEUR: and largeur // LARGEUR == hauteur // HAUTEUR:
return image.resize((LARGEUR, HAUTEUR), PLUS_PROCHE_VOISIN) 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é. # au centre, sans la déformer. Ce qui dépasse est coupé.
avertir(nom, f"taille {largeur} × {hauteur} au lieu de {LARGEUR} × {HAUTEUR} : " avertir(nom, f"taille {largeur} × {hauteur} au lieu de {LARGEUR} × {HAUTEUR} : "
"image centrée (ce qui dépasse est coupé)") "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 : # 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). # on prend le nouveau s'il existe, sinon l'ancien (Raspbian 11 a Pillow 8).
PLUS_PROCHE_VOISIN = getattr(Image, "Resampling", Image).NEAREST 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() _deja_averti = set()
@@ -241,18 +257,52 @@ def charger(chemin):
# ============================================================================= # =============================================================================
def jouer(etapes, duree_minimum=DUREE_MINIMUM): 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() debut = time.monotonic()
while True: while True:
for image, duree in etapes: for image, duree in etapes:
moment = time.monotonic() moment = time.monotonic()
matrice.montrer(image) matrice.montrer(image)
# On retire le temps déjà passé à afficher, pour garder le rythme. # 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: if time.monotonic() - debut >= duree_minimum:
break 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): def ecran_d_attente(dossier):
"""Quand il n'y a rien à jouer : un petit point qui respire en bas à droite.""" """Quand il n'y a rien à jouer : un petit point qui respire en bas à droite."""
print(f"Aucune animation dans {dossier} : en attente…") 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) lumiere = int(60 + 60 * abs(25 - i) / 25)
matrice.pixel(LARGEUR - 1, HAUTEUR - 1, (0, lumiere, lumiere)) matrice.pixel(LARGEUR - 1, HAUTEUR - 1, (0, lumiere, lumiere))
matrice.montrer() 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(): def en_horaires():
@@ -280,7 +407,7 @@ def en_horaires():
# ============================================================================= # =============================================================================
# 6. LE PROGRAMME PRINCIPAL # 7. LE PROGRAMME PRINCIPAL
# ============================================================================= # =============================================================================
def main(): def main():
@@ -295,33 +422,33 @@ def main():
matrice.demarrer(simulateur=args.simulateur) matrice.demarrer(simulateur=args.simulateur)
dossier = args.dossier or trouver_dossier() 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: try:
while True: # boucle infinie : la vitrine tourne jusqu'à ce qu'on l'arrête while True: # boucle infinie : la vitrine tourne jusqu'à ce qu'on l'arrête
if not en_horaires(): if not en_horaires():
ecrire_en_cours("(éteint : en dehors des horaires)")
matrice.eteindre() matrice.eteindre()
matrice.attendre(30_000) # on revérifie l'heure toutes les 30 s matrice.attendre(30_000) # on revérifie l'heure toutes les 30 s
continue continue
# Un seul fichier demandé, ou tout le dossier ? # 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: if not playlist:
ecrire_en_cours("(rien à jouer)")
ecran_d_attente(dossier) ecran_d_attente(dossier)
traiter_demandes(dossier)
continue continue
for chemin in playlist: for chemin in playlist:
print("▶", os.path.relpath(chemin, dossier) if not args.chemin else chemin) traiter_demandes(dossier) # « jouer maintenant » depuis le web ?
try: if os.path.exists(chemin): # (elle a pu être supprimée entre-temps)
etapes = charger(chemin) jouer_animation(chemin, dossier)
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)
if not en_horaires(): if not en_horaires():
break break
@@ -333,6 +460,7 @@ def main():
# on ne laisse jamais la matrice allumée en partant. # on ne laisse jamais la matrice allumée en partant.
try: try:
matrice.eteindre() matrice.eteindre()
os.remove(FICHIER_EN_COURS)
except Exception: except Exception:
pass pass
+61 -21
View File
@@ -68,10 +68,25 @@ from PIL import Image
# 1. RÉGLAGES # 1. RÉGLAGES
# ============================================================================= # =============================================================================
# Taille de la matrice : 32 colonnes (largeur) × 8 lignes (hauteur). # Le PANNEAU, tel qu'il est fabriqué : 32 colonnes × 8 lignes de LED,
LARGEUR = 32 # câblées en serpentin par colonnes de 8. Ça ne change jamais.
HAUTEUR = 8 COLONNES_PANNEAU = 32
NB_LEDS = LARGEUR * HAUTEUR # = 256 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 # 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 # 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). # (ex. : alimentation d'établi limitée à 3 A → 2500 mA ici).
COURANT_MAX_MA = 2500 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. # 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 inversés (gauche/droite) → INVERSER_X = True
# - rouge et vert en bas au lieu d'en haut → INVERSER_Y = 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_X = False
INVERSER_Y = 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 # 3. DE LA POSITION (x, y) AU NUMÉRO DE LED
# ============================================================================= # =============================================================================
# #
# Pour dessiner, on pense en coordonnées : # Pour dessiner, on pense en coordonnées, sur l'IMAGE telle qu'on la voit :
# x = colonne, de 0 (à gauche) à 31 (à droite) # x = colonne, de 0 (à gauche) à LARGEUR - 1 (à droite)
# y = ligne, de 0 (en haut) à 7 (en bas) # y = ligne, de 0 (en haut) à HAUTEUR - 1 (en bas)
# Le point (0, 0) est en haut à gauche, comme dans Krita. # 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 # Pour trouver la LED correspondante, trois étapes :
# zigzags (« serpentin ») : elle descend la 1re colonne, remonte la 2e…
# #
# x=0 x=1 x=2 x=3 # 1. Retourner l'image si besoin (INVERSER_X / INVERSER_Y).
# y=0 0 15 16 31 … #
# y=1 1 14 17 30 # 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
# … … … … … # … … … … …
# y=7 7 → 8 23 → 24 # py=7 7 → 8 23 → 24
# #
# C'est la même fonction que dans matrice_8x32.ino, traduite en Python. # L'étape 3 est la même fonction que dans matrice_8x32.ino.
def XY(x, y): 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: if INVERSER_X:
x = (LARGEUR - 1) - x x = (LARGEUR - 1) - x
if INVERSER_Y: if INVERSER_Y:
y = (HAUTEUR - 1) - y y = (HAUTEUR - 1) - y
if x % 2 == 0: # 2. Tourner : position (px, py) sur le panneau couché.
# Colonne paire : la chaîne DESCEND. if ROTATION == 90:
return x * HAUTEUR + y 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: else:
# Colonne impaire : la chaîne REMONTE. # 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: class Simulateur:
"""Une fenêtre sur l'ordinateur qui imite la matrice (256 ronds).""" """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 MARGE = 4 # espace entre deux pixels
def __init__(self, titre="Simulateur — matrice 8 × 32"): def __init__(self, titre="Simulateur — matrice 8 × 32"):
+28 -16
View File
@@ -67,8 +67,8 @@ def anim_arc_en_ciel():
for temps in range(256): for temps in range(256):
for x in range(LARGEUR): for x in range(LARGEUR):
for y in range(HAUTEUR): for y in range(HAUTEUR):
# La teinte dépend de la colonne et du temps qui passe. # La teinte dépend de la position et du temps qui passe.
pixel(x, y, teinte(x * 8 + temps * 2)) pixel(x, y, teinte((x + y) * 8 + temps * 2))
montrer() montrer()
attendre(20) attendre(20)
@@ -95,16 +95,16 @@ def anim_pluie():
# --- Animation 3 : un dessin en pixel art qui défile --- # --- 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 = [ DESSIN = [
"..RR.RR..", ".RR..RR.",
".RBRRRRR.", "RBBRRRRR",
".RRRRRRR.", "RBRRRRRR",
".RRRRRRR.", "RRRRRRRR",
"..RRRRR..", ".RRRRRR.",
"...RRR...", "..RRRR..",
"....R....", "...RR...",
".........",
] ]
# Un « dictionnaire » associe chaque lettre à une couleur. # Un « dictionnaire » associe chaque lettre à une couleur.
@@ -114,18 +114,30 @@ 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 y, ligne in enumerate(DESSIN): # enumerate donne le numéro ET la ligne
for x, lettre in enumerate(ligne): for x, lettre in enumerate(ligne):
if lettre in COULEUR_DE_LETTRE: 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(): def anim_dessin_qui_defile():
largeur_dessin = len(DESSIN[0]) # len = longueur de la ligne largeur_dessin = len(DESSIN[0]) # len = longueur de la ligne
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): for x in range(LARGEUR, -largeur_dessin - 1, -1):
effacer() effacer()
dessiner_dessin(x) dessiner_dessin(x, y)
montrer() montrer()
attendre(70) attendre(70)
@@ -144,8 +156,8 @@ def anim_etincelles():
def anim_ma_premiere(): def anim_ma_premiere():
for x in range(LARGEUR): for x in range(LARGEUR):
effacer() effacer()
pixel(x, 3, JAUNE) pixel(x, HAUTEUR // 2 - 1, JAUNE) # deux points au milieu de la hauteur
pixel(x, 4, JAUNE) pixel(x, HAUTEUR // 2, JAUNE)
montrer() montrer()
attendre(50) attendre(50)
+43 -22
View File
@@ -25,12 +25,15 @@ Pillow, puis les jouer comme des dessins.
import math import math
import os import os
import sys
from PIL import Image from PIL import Image
ICI = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ICI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ANIMATIONS = os.path.join(ICI, "animations") 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(): def nouvelle_image():
@@ -56,14 +59,13 @@ PETIT_COEUR = [
"...R...", "...R...",
] ]
GRAND_COEUR = [ GRAND_COEUR = [
".RRR.RRR.", ".RR..RR.",
"RBBRRRRRR", "RBBRRRRR",
"RBRRRRRRR", "RBRRRRRR",
"RRRRRRRRR", "RRRRRRRR",
".RRRRRRR.", ".RRRRRR.",
"..RRRRR..", "..RRRR..",
"...RRR...", "...RR...",
"....R....",
] ]
@@ -75,7 +77,7 @@ def coeur_qui_bat():
(GRAND_COEUR, 160)]: (GRAND_COEUR, 160)]:
im = nouvelle_image() im = nouvelle_image()
largeur = len(motif[0]) 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) images.append(im)
durees.append(duree) durees.append(duree)
images[0].save(os.path.join(ANIMATIONS, "01_coeur_qui_bat.gif"), save_all=True, images[0].save(os.path.join(ANIMATIONS, "01_coeur_qui_bat.gif"), save_all=True,
@@ -103,26 +105,43 @@ POLICE = {
} }
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))
def texte_defilant(texte="VITRINE PIXELS"): def texte_defilant(texte="VITRINE PIXELS"):
# 1) On écrit tout le texte sur une longue bande. # 1) On écrit tout le texte sur une longue bande :
largeur_totale = sum(len(POLICE[c][0]) + 1 for c in texte) # - matrice couchée : les lettres côte à côte, la bande défile vers la gauche ;
bande = Image.new("RGB", (largeur_totale, H)) # - matrice debout : les lettres les unes SOUS les autres (comme une
x = 0 # 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): for i, c in enumerate(texte):
# chaque lettre a sa propre teinte : un petit arc-en-ciel x0 = (L - len(POLICE[c][0])) // 2 # lettre centrée
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 y, ligne in enumerate(POLICE[c]):
for dx, p in enumerate(ligne): for dx, p in enumerate(ligne):
if p == "#": if p == "#":
bande.putpixel((x + dx, y), (r, v, b)) 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 x += len(POLICE[c][0]) + 1
fenetre = L
# 2) On fait glisser une « fenêtre » de 32 pixels sur la bande. # 2) On fait glisser une « fenêtre » de la taille de la matrice sur la bande.
images = [] images = []
for decalage in range(-L, largeur_totale + 1): for decalage in range(-fenetre, longueur + 1):
im = nouvelle_image() im = nouvelle_image()
im.paste(bande, (-decalage, 0)) im.paste(bande, (0, -decalage) if DEBOUT else (-decalage, 0))
images.append(im) images.append(im)
images[0].save(os.path.join(ANIMATIONS, "02_texte_defilant.gif"), save_all=True, images[0].save(os.path.join(ANIMATIONS, "02_texte_defilant.gif"), save_all=True,
append_images=images[1:], duration=60, loop=0) append_images=images[1:], duration=60, loop=0)
@@ -137,7 +156,9 @@ def vague(nb_images=40, ips=20):
im = nouvelle_image() im = nouvelle_image()
phase = 2 * math.pi * n / nb_images phase = 2 * math.pi * n / nb_images
for x in range(L): 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): for y in range(H):
if H - 1 - y <= hauteur: # sous la crête de la vague if H - 1 - y <= hauteur: # sous la crête de la vague
# 3 bleus seulement, du plus clair (crête) au plus profond : # 3 bleus seulement, du plus clair (crête) au plus profond :
+1 -1
View File
@@ -416,7 +416,7 @@ def brochage():
def chaine(): def chaine():
s = Svg(1200, 330, "De Krita à la matrice : le trajet d'une animation") s = Svg(1200, 330, "De Krita à la matrice : le trajet d'une animation")
etapes = [ 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)"]), ("#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"]), ("#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)"]), ("#b08900", "4. Déposer", ["dossier « animations »", "carte SD ou réseau", "(01_, 02_… = ordre)"]),
+10 -2
View File
@@ -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") 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 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): def pixel_eteint_sur_matrice(r, v, b):
@@ -137,9 +137,17 @@ def verifier(chemin):
base = os.path.join(DOSSIER_APERCUS, os.path.splitext(nom)[0] + "_apercu") base = os.path.join(DOSSIER_APERCUS, os.path.splitext(nom)[0] + "_apercu")
images = [dessiner_leds(image) for image, _ in etapes] 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] montrees = images[:MAX_PLANCHE]
l, h = images[0].size l, h = images[0].size
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)) planche = Image.new("RGB", (l + 50, len(montrees) * (h + 6)), (255, 255, 255))
crayon = ImageDraw.Draw(planche) crayon = ImageDraw.Draw(planche)
for i, im in enumerate(montrees): for i, im in enumerate(montrees):
+2 -2
View File
@@ -7,8 +7,8 @@
<rect x="30" y="90" width="170" height="16" rx="0" fill="#7b3fa0"/> <rect x="30" y="90" width="170" height="16" rx="0" fill="#7b3fa0"/>
<text x="115.0" y="90" font-size="18" fill="#fff" text-anchor="middle" font-weight="bold">1. Dessiner</text> <text x="115.0" y="90" font-size="18" fill="#fff" text-anchor="middle" font-weight="bold">1. Dessiner</text>
<text x="115.0" y="140" font-size="14" fill="#222" text-anchor="middle" font-weight="bold">Krita</text> <text x="115.0" y="140" font-size="14" fill="#222" text-anchor="middle" font-weight="bold">Krita</text>
<text x="115.0" y="166" font-size="13" fill="#555" text-anchor="middle">document 32 × 8 px</text> <text x="115.0" y="166" font-size="13" fill="#555" text-anchor="middle">document 8 × 32 px</text>
<text x="115.0" y="192" font-size="13" fill="#555" text-anchor="middle">pixel art, sans flou</text> <text x="115.0" y="192" font-size="13" fill="#555" text-anchor="middle">(vertical), sans flou</text>
<line x1="204" y1="155" x2="224" y2="155" stroke="#555" stroke-width="3" marker-end="url(#flecheg)"/> <line x1="204" y1="155" x2="224" y2="155" stroke="#555" stroke-width="3" marker-end="url(#flecheg)"/>
<rect x="228" y="60" width="170" height="190" rx="12" fill="#ffffff" stroke="#b5418f" stroke-width="3"/> <rect x="228" y="60" width="170" height="190" rx="12" fill="#ffffff" stroke="#b5418f" stroke-width="3"/>
<rect x="228" y="60" width="170" height="46" rx="12" fill="#b5418f"/> <rect x="228" y="60" width="170" height="46" rx="12" fill="#b5418f"/>

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

+527
View File
@@ -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/<path:relatif>")
def apercu(relatif):
"""L'aperçu GIF d'une animation (affiché dans la page par une balise <img>)."""
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://<adresse du Pi>:{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()
+2 -1
View File
@@ -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. 1. LES COINS : un pixel de couleur dans chaque coin, 5 secondes.
ROUGE en haut à gauche VERT en haut à droite ROUGE en haut à gauche VERT en haut à droite
BLEU en bas à gauche BLANC en bas à 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 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. dans l'ordre où le signal les traverse. On voit le serpentin.
+12
View File
@@ -0,0 +1,12 @@
{# Petit morceau de page, inclus dans chaque fiche : les informations d'une animation « a ». #}
{% if a.erreur %}
<span class="alerte">⚠ fichier illisible : {{ a.erreur }}</span>
{% else %}
<span class="infos">
{% 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 %}
</span>
{% if not a.infos.taille_ok %}
<span class="alerte">⚠ {{ a.infos.taille[0] }} × {{ a.infos.taille[1] }} px au lieu de {{ largeur }} × {{ hauteur }}</span>
{% endif %}
{% endif %}
+208
View File
@@ -0,0 +1,208 @@
<!doctype html>
{#
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.)
#}
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Vitrine pixels</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body class="{{ 'debout' if hauteur > largeur else 'couchee' }}">
<header>
<h1><span class="logo" aria-hidden="true"></span>Vitrine pixels</h1>
<div class="statut">
<span class="pastille" aria-hidden="true"></span>
En ce moment : <strong id="en-cours">{{ en_cours or "—" }}</strong>
<form method="post" action="{{ url_for('action') }}">
<input type="hidden" name="action" value="suivante">
<button type="submit" class="bouton">Suivante ⏭</button>
</form>
</div>
</header>
<main>
{# Les messages « flash » : résultat de la dernière action #}
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="messages" id="messages" role="status">
{% for categorie, message in messages %}
<p class="message {{ categorie }}">{{ message }}</p>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<!-- =================== AJOUTER =================== -->
<section id="ajouter">
<h2>Ajouter des animations</h2>
<div class="deux-colonnes">
<form class="carte" method="post" action="{{ url_for('ajouter') }}" enctype="multipart/form-data">
<h3>Un ou plusieurs fichiers</h3>
<p class="aide">GIF animé, PNG animé, WebP ou image fixe, exportés de Krita en
<strong>{{ largeur }} × {{ hauteur }} pixels</strong>. Chaque fichier devient une animation.</p>
<label class="depot">
<input type="file" name="fichiers" multiple accept="{{ extensions }},image/*">
<span>Choisir des fichiers…</span>
</label>
<button type="submit" class="bouton principal">Envoyer</button>
</form>
<form class="carte" method="post" action="{{ url_for('ajouter_sequence') }}" enctype="multipart/form-data">
<h3>Une séquence de PNG</h3>
<p class="aide">Toutes les images d'un export Krita « séquence d'images »
(<code>feu_0000.png</code>, <code>feu_0001.png</code>…), pour une seule animation.</p>
<label class="depot">
<input type="file" name="images" multiple accept=".png,image/png">
<span>Choisir les PNG…</span>
</label>
<div class="champs">
<label>Nom <input type="text" name="nom" placeholder="feu_de_camp" required></label>
<label>Images/s <input type="number" name="ips" value="12" min="0.5" max="60" step="0.5"></label>
</div>
<button type="submit" class="bouton principal">Envoyer</button>
</form>
</div>
<p class="aide">Maximum {{ taille_max }} Mo par envoi. Les nouvelles animations
se placent à la fin de la liste.</p>
</section>
<!-- =================== JOUÉES =================== -->
<section id="jouees">
<h2>Jouées, dans cet ordre <span class="compte">{{ actives|length }}</span></h2>
{% if not actives %}
<p class="vide">Aucune animation active : la matrice affiche un petit point d'attente.</p>
{% endif %}
<ol class="liste">
{% for a in actives %}
<li class="fiche" id="a-{{ loop.index }}" data-relatif="{{ a.relatif }}">
<span class="numero">{{ loop.index }}</span>
<img class="apercu" src="{{ url_for('apercu', relatif=a.relatif) }}?v={{ a.infos.version }}"
alt="Aperçu de {{ a.titre }}" loading="lazy">
<div class="details">
<strong>{{ a.titre }}</strong>
{% include "infos.html" %}
</div>
<div class="boutons">
{% 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)] %}
<form method="post" action="{{ url_for('action') }}"
{% if quoi == "supprimer" %}data-confirmer="Mettre « {{ a.titre }} » à la corbeille ?"{% endif %}>
<input type="hidden" name="action" value="{{ quoi }}">
<input type="hidden" name="nom" value="{{ a.nom }}">
<input type="hidden" name="relatif" value="{{ a.relatif }}">
<input type="hidden" name="ancre" value="{{ ancre }}">
<button type="submit" class="bouton icone {{ quoi }}" title="{{ titre }}"
aria-label="{{ titre }}" {% if not actif %}disabled{% endif %}>{{ signe }}</button>
</form>
{% endfor %}
</div>
</li>
{% endfor %}
</ol>
</section>
<!-- =================== DÉSACTIVÉES =================== -->
<section id="desactivees">
<h2>Pas jouées <span class="compte">{{ desactivees|length }}</span></h2>
{% if not desactivees %}
<p class="vide">Rien ici. Le bouton ⏸ d'une animation la range ici sans la supprimer.</p>
{% endif %}
<ul class="liste">
{% for a in desactivees %}
<li class="fiche eteinte" data-relatif="{{ a.relatif }}">
<span class="numero">–</span>
<img class="apercu" src="{{ url_for('apercu', relatif=a.relatif) }}?v={{ a.infos.version }}"
alt="Aperçu de {{ a.titre }}" loading="lazy">
<div class="details">
<strong>{{ a.titre }}</strong>
{% include "infos.html" %}
</div>
<div class="boutons">
{% for quoi, signe, titre in [("activer", "+", "Remettre dans la liste"),
("jouer", "▶", "Jouer maintenant (une fois)"),
("supprimer", "✕", "Supprimer")] %}
<form method="post" action="{{ url_for('action') }}"
{% if quoi == "supprimer" %}data-confirmer="Mettre « {{ a.titre }} » à la corbeille ?"{% endif %}>
<input type="hidden" name="action" value="{{ quoi }}">
<input type="hidden" name="nom" value="{{ a.nom }}">
<input type="hidden" name="relatif" value="{{ a.relatif }}">
<input type="hidden" name="ancre" value="#desactivees">
<button type="submit" class="bouton icone {{ quoi }}" title="{{ titre }}"
aria-label="{{ titre }}">{{ signe }}</button>
</form>
{% endfor %}
</div>
</li>
{% endfor %}
</ul>
</section>
</main>
<footer>
<form method="post" action="{{ url_for('action') }}"
data-confirmer="Effacer DÉFINITIVEMENT les {{ nb_corbeille }} élément(s) de la corbeille ?">
Corbeille : {{ nb_corbeille }} élément(s)
<input type="hidden" name="action" value="vider_corbeille">
<button type="submit" class="bouton" {% if not nb_corbeille %}disabled{% endif %}>Vider</button>
</form>
<p>Adresses de cette page :
{% for a in adresses %}<code>http://{{ a }}{{ port }}</code>{% if not loop.last %} · {% endif %}{% endfor %}
<br>(celle qui commence comme l'adresse de votre appareil marche depuis votre réseau)</p>
<p>Dossier : <code>{{ dossier }}</code></p>
</footer>
<script>
// Demander une confirmation avant les actions irréversibles.
document.querySelectorAll("form[data-confirmer]").forEach(function (form) {
form.addEventListener("submit", function (e) {
if (!confirm(form.dataset.confirmer)) e.preventDefault();
});
});
// Afficher le nom des fichiers choisis dans les zones de dépôt.
document.querySelectorAll(".depot input").forEach(function (champ) {
champ.addEventListener("change", function () {
var n = champ.files.length;
champ.nextElementSibling.textContent =
n === 0 ? "Choisir…" : n === 1 ? champ.files[0].name : n + " fichiers choisis";
});
});
// Les messages disparaissent tout seuls après 6 secondes (sauf les erreurs).
var messages = document.getElementById("messages");
if (messages && !messages.querySelector(".erreur")) {
setTimeout(function () { messages.remove(); }, 6000);
}
// Toutes les 2 secondes, demander au serveur ce que joue la matrice,
// et mettre en valeur la fiche correspondante.
function rafraichir() {
fetch("{{ url_for('etat') }}")
.then(function (r) { return r.json(); })
.then(function (etat) {
document.getElementById("en-cours").textContent = etat.en_cours || "—";
document.querySelectorAll(".fiche").forEach(function (f) {
f.classList.toggle("joue", f.dataset.relatif === etat.en_cours);
});
})
.catch(function () {});
}
rafraichir();
setInterval(rafraichir, 2000);
</script>
</body>
</html>
+172
View File
@@ -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; }
+3 -1
View File
@@ -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. - 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). - 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`). - 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`. - Schémas générés par `outils/schemas.py`, exemples et palette Krita par `outils/generer_exemples.py`.
### Chiffres clés ### Chiffres clés