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>
@@ -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>.
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ le fichier dans le dossier des animations.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
> **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
|
||||||
|
|||||||
@@ -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` |
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 279 B After Width: | Height: | Size: 299 B |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 171 B After Width: | Height: | Size: 149 B |
|
Before Width: | Height: | Size: 176 B After Width: | Height: | Size: 151 B |
|
Before Width: | Height: | Size: 174 B After Width: | Height: | Size: 147 B |
|
Before Width: | Height: | Size: 174 B After Width: | Height: | Size: 155 B |
|
Before Width: | Height: | Size: 171 B After Width: | Height: | Size: 155 B |
|
Before Width: | Height: | Size: 181 B After Width: | Height: | Size: 156 B |
|
Before Width: | Height: | Size: 166 B After Width: | Height: | Size: 154 B |
|
Before Width: | Height: | Size: 170 B After Width: | Height: | Size: 154 B |
|
Before Width: | Height: | Size: 165 B After Width: | Height: | Size: 152 B |
|
Before Width: | Height: | Size: 163 B After Width: | Height: | Size: 153 B |
|
Before Width: | Height: | Size: 161 B After Width: | Height: | Size: 152 B |
|
Before Width: | Height: | Size: 166 B After Width: | Height: | Size: 152 B |
|
Before Width: | Height: | Size: 160 B After Width: | Height: | Size: 138 B |
|
Before Width: | Height: | Size: 167 B After Width: | Height: | Size: 144 B |
|
Before Width: | Height: | Size: 168 B After Width: | Height: | Size: 139 B |
|
Before Width: | Height: | Size: 172 B After Width: | Height: | Size: 135 B |
|
Before Width: | Height: | Size: 165 B After Width: | Height: | Size: 135 B |
|
Before Width: | Height: | Size: 171 B After Width: | Height: | Size: 139 B |
|
Before Width: | Height: | Size: 169 B After Width: | Height: | Size: 141 B |
|
Before Width: | Height: | Size: 176 B After Width: | Height: | Size: 144 B |
|
Before Width: | Height: | Size: 177 B After Width: | Height: | Size: 145 B |
|
Before Width: | Height: | Size: 166 B After Width: | Height: | Size: 148 B |
|
Before Width: | Height: | Size: 169 B After Width: | Height: | Size: 145 B |
|
Before Width: | Height: | Size: 169 B After Width: | Height: | Size: 135 B |
|
Before Width: | Height: | Size: 170 B After Width: | Height: | Size: 144 B |
|
Before Width: | Height: | Size: 175 B After Width: | Height: | Size: 141 B |
|
Before Width: | Height: | Size: 164 B After Width: | Height: | Size: 154 B |
|
Before Width: | Height: | Size: 168 B After Width: | Height: | Size: 146 B |
|
Before Width: | Height: | Size: 167 B After Width: | Height: | Size: 142 B |
|
Before Width: | Height: | Size: 168 B After Width: | Height: | Size: 149 B |
|
Before Width: | Height: | Size: 167 B After Width: | Height: | Size: 150 B |
|
Before Width: | Height: | Size: 165 B After Width: | Height: | Size: 153 B |
|
Before Width: | Height: | Size: 170 B After Width: | Height: | Size: 145 B |
|
Before Width: | Height: | Size: 170 B After Width: | Height: | Size: 147 B |
|
Before Width: | Height: | Size: 164 B After Width: | Height: | Size: 143 B |
|
Before Width: | Height: | Size: 169 B After Width: | Height: | Size: 138 B |
|
Before Width: | Height: | Size: 166 B After Width: | Height: | Size: 139 B |
|
Before Width: | Height: | Size: 173 B After Width: | Height: | Size: 151 B |
|
Before Width: | Height: | Size: 177 B After Width: | Height: | Size: 144 B |
|
Before Width: | Height: | Size: 177 B After Width: | Height: | Size: 154 B |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.6 KiB |
@@ -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 "================================================================"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
# … … … … …
|
|
||||||
# y=7 7 → 8 23 → 24
|
|
||||||
#
|
#
|
||||||
# C'est la même fonction que dans matrice_8x32.ino, traduite en Python.
|
# 2. Tourner : passer de la position sur l'IMAGE à la position sur le
|
||||||
|
# PANNEAU (px de 0 à 31, py de 0 à 7, panneau vu couché). Exemple,
|
||||||
|
# panneau debout (ROTATION = 90, tourné d'un quart de tour vers la
|
||||||
|
# droite) : le haut de l'image correspond à la gauche du panneau, et la
|
||||||
|
# droite de l'image au haut du panneau.
|
||||||
|
#
|
||||||
|
# 3. Le serpentin : les LED sont numérotées dans l'ordre de la chaîne, qui
|
||||||
|
# descend la 1re colonne du panneau, remonte la 2e…
|
||||||
|
#
|
||||||
|
# px=0 px=1 px=2 px=3
|
||||||
|
# py=0 0 15 16 31 …
|
||||||
|
# py=1 1 14 17 30
|
||||||
|
# … … … … …
|
||||||
|
# py=7 7 → 8 23 → 24
|
||||||
|
#
|
||||||
|
# L'étape 3 est la même fonction que dans matrice_8x32.ino.
|
||||||
|
|
||||||
def XY(x, y):
|
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"):
|
||||||
|
|||||||
@@ -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,20 +114,32 @@ COULEUR_DE_LETTRE = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def dessiner_dessin(depart_x):
|
def dessiner_dessin(depart_x, depart_y):
|
||||||
for y, ligne in enumerate(DESSIN): # enumerate donne le numéro ET la ligne
|
for 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
|
||||||
for x in range(LARGEUR, -largeur_dessin - 1, -1):
|
hauteur_dessin = len(DESSIN) # = nombre de lignes
|
||||||
effacer()
|
if HAUTEUR > LARGEUR:
|
||||||
dessiner_dessin(x)
|
# Matrice DEBOUT : le dessin monte du bas vers le haut.
|
||||||
montrer()
|
x = (LARGEUR - largeur_dessin) // 2 # centré
|
||||||
attendre(70)
|
for y in range(HAUTEUR, -hauteur_dessin - 1, -1):
|
||||||
|
effacer()
|
||||||
|
dessiner_dessin(x, y)
|
||||||
|
montrer()
|
||||||
|
attendre(70)
|
||||||
|
else:
|
||||||
|
# Matrice COUCHÉE : le dessin passe de droite à gauche.
|
||||||
|
y = (HAUTEUR - hauteur_dessin) // 2
|
||||||
|
for x in range(LARGEUR, -largeur_dessin - 1, -1):
|
||||||
|
effacer()
|
||||||
|
dessiner_dessin(x, y)
|
||||||
|
montrer()
|
||||||
|
attendre(70)
|
||||||
|
|
||||||
|
|
||||||
# --- Animation 4 : étincelles ---
|
# --- Animation 4 : étincelles ---
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 texte_defilant(texte="VITRINE PIXELS"):
|
def couleur_de_lettre(i, n):
|
||||||
# 1) On écrit tout le texte sur une longue bande.
|
"""Chaque lettre a sa propre teinte : un petit arc-en-ciel."""
|
||||||
largeur_totale = sum(len(POLICE[c][0]) + 1 for c in texte)
|
return tuple(int(255 * (0.5 + 0.5 * math.cos(2 * math.pi * (i / n + k / 3))))
|
||||||
bande = Image.new("RGB", (largeur_totale, H))
|
for k in range(3))
|
||||||
x = 0
|
|
||||||
for i, c in enumerate(texte):
|
|
||||||
# chaque lettre a sa propre teinte : un petit arc-en-ciel
|
|
||||||
r, v, b = [int(255 * (0.5 + 0.5 * math.cos(2 * math.pi * (i / len(texte) + k / 3))))
|
|
||||||
for k in range(3)]
|
|
||||||
for y, ligne in enumerate(POLICE[c]):
|
|
||||||
for dx, p in enumerate(ligne):
|
|
||||||
if p == "#":
|
|
||||||
bande.putpixel((x + dx, y), (r, v, b))
|
|
||||||
x += len(POLICE[c][0]) + 1
|
|
||||||
|
|
||||||
# 2) On fait glisser une « fenêtre » de 32 pixels sur la bande.
|
|
||||||
|
def texte_defilant(texte="VITRINE PIXELS"):
|
||||||
|
# 1) On écrit tout le texte sur une longue bande :
|
||||||
|
# - matrice couchée : les lettres côte à côte, la bande défile vers la gauche ;
|
||||||
|
# - matrice debout : les lettres les unes SOUS les autres (comme une
|
||||||
|
# enseigne verticale), la bande défile vers le haut.
|
||||||
|
if DEBOUT:
|
||||||
|
bande = Image.new("RGB", (L, len(texte) * 8))
|
||||||
|
for i, c in enumerate(texte):
|
||||||
|
x0 = (L - len(POLICE[c][0])) // 2 # lettre centrée
|
||||||
|
for y, ligne in enumerate(POLICE[c]):
|
||||||
|
for dx, p in enumerate(ligne):
|
||||||
|
if p == "#":
|
||||||
|
bande.putpixel((x0 + dx, i * 8 + y), couleur_de_lettre(i, len(texte)))
|
||||||
|
longueur, fenetre = bande.size[1], H
|
||||||
|
else:
|
||||||
|
longueur = sum(len(POLICE[c][0]) + 1 for c in texte)
|
||||||
|
bande = Image.new("RGB", (longueur, H))
|
||||||
|
x = 0
|
||||||
|
for i, c in enumerate(texte):
|
||||||
|
for y, ligne in enumerate(POLICE[c]):
|
||||||
|
for dx, p in enumerate(ligne):
|
||||||
|
if p == "#":
|
||||||
|
bande.putpixel((x + dx, y), couleur_de_lettre(i, len(texte)))
|
||||||
|
x += len(POLICE[c][0]) + 1
|
||||||
|
fenetre = L
|
||||||
|
|
||||||
|
# 2) On fait glisser une « fenêtre » de la taille de la matrice sur la bande.
|
||||||
images = []
|
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 :
|
||||||
|
|||||||
@@ -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)"]),
|
||||||
|
|||||||
@@ -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,14 +137,22 @@ 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
|
||||||
planche = Image.new("RGB", (l + 50, len(montrees) * (h + 6)), (255, 255, 255))
|
if h > l:
|
||||||
crayon = ImageDraw.Draw(planche)
|
planche = Image.new("RGB", (len(montrees) * (l + 8), h + 24), (255, 255, 255))
|
||||||
for i, im in enumerate(montrees):
|
crayon = ImageDraw.Draw(planche)
|
||||||
planche.paste(im, (50, i * (h + 6)))
|
for i, im in enumerate(montrees):
|
||||||
crayon.text((6, i * (h + 6) + h // 2 - 6), str(i + 1), fill=(0, 0, 0))
|
planche.paste(im, (i * (l + 8), 24))
|
||||||
|
crayon.text((i * (l + 8) + l // 2 - 6, 6), str(i + 1), fill=(0, 0, 0))
|
||||||
|
else:
|
||||||
|
planche = Image.new("RGB", (l + 50, len(montrees) * (h + 6)), (255, 255, 255))
|
||||||
|
crayon = ImageDraw.Draw(planche)
|
||||||
|
for i, im in enumerate(montrees):
|
||||||
|
planche.paste(im, (50, i * (h + 6)))
|
||||||
|
crayon.text((6, i * (h + 6) + h // 2 - 6), str(i + 1), fill=(0, 0, 0))
|
||||||
planche.save(base + ".png")
|
planche.save(base + ".png")
|
||||||
print(f" Aperçu : {base}.png", end="")
|
print(f" Aperçu : {base}.png", end="")
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
@@ -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()
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
@@ -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; }
|
||||||
@@ -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
|
||||||
|
|||||||