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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bach
2026-10-08 16:08:18 +02:00
co-authored by Claude Opus 5.5
parent 98fca33142
commit 2d4b0d5a5f
63 changed files with 1592 additions and 156 deletions
+150 -22
View File
@@ -42,13 +42,17 @@ ORGANISATION DU PROGRAMME
2. Trouver les animations dans le dossier.
3. Charger une animation : la découper en images de 32 × 8.
4. Jouer une animation.
5. Les horaires (allumer la vitrine seulement à certaines heures).
6. Le programme principal.
5. Parler avec le serveur web (serveur_web.py).
6. Les horaires (allumer la vitrine seulement à certaines heures).
7. Le programme principal.
"""
import argparse # pour lire les options (--simulateur, --dossier…)
import sys
import datetime # pour connaître l'heure (horaires de la vitrine)
import os # pour parcourir les dossiers et les fichiers
import signal # pour réagir quand Linux demande au programme de s'arrêter
import tempfile # pour trouver le dossier temporaire de l'ordinateur
import time
from PIL import Image, ImageSequence
@@ -169,7 +173,17 @@ def adapter_image(image, nom):
and largeur // LARGEUR == hauteur // HAUTEUR:
return image.resize((LARGEUR, HAUTEUR), PLUS_PROCHE_VOISIN)
# 3) Autre taille (par exemple 39 × 7, le format final) : on la pose
# 3) L'image est dessinée dans l'AUTRE SENS (couchée, 32 × 8, alors que la
# matrice est debout, ou l'inverse) : on la fait tourner d'un quart de
# tour, comme un texte sur le dos d'un livre (il se lit de bas en haut).
if LARGEUR != HAUTEUR and largeur % HAUTEUR == 0 and hauteur % LARGEUR == 0 \
and largeur // HAUTEUR == hauteur // LARGEUR:
avertir(nom, f"image couchée ({largeur} × {hauteur}) sur une matrice "
f"{'debout' if HAUTEUR > LARGEUR else 'couchée'} : tournée d'un quart de tour")
image = image.transpose(UN_QUART_DE_TOUR)
return image.resize((LARGEUR, HAUTEUR), PLUS_PROCHE_VOISIN)
# 4) Autre taille (par exemple 39 × 7, le format final) : on la pose
# au centre, sans la déformer. Ce qui dépasse est coupé.
avertir(nom, f"taille {largeur} × {hauteur} au lieu de {LARGEUR} × {HAUTEUR} : "
"image centrée (ce qui dépasse est coupé)")
@@ -181,6 +195,8 @@ def adapter_image(image, nom):
# Le nom du filtre « plus proche voisin » a changé avec Pillow 9.1 :
# on prend le nouveau s'il existe, sinon l'ancien (Raspbian 11 a Pillow 8).
PLUS_PROCHE_VOISIN = getattr(Image, "Resampling", Image).NEAREST
# Idem pour « tourner d'un quart de tour dans le sens inverse des aiguilles d'une montre ».
UN_QUART_DE_TOUR = getattr(Image, "Transpose", Image).ROTATE_90
_deja_averti = set()
@@ -241,18 +257,52 @@ def charger(chemin):
# =============================================================================
def jouer(etapes, duree_minimum=DUREE_MINIMUM):
"""Joue la liste d'étapes, et la rejoue jusqu'à atteindre la durée minimum."""
"""Joue la liste d'étapes, et la rejoue jusqu'à atteindre la durée minimum.
S'arrête plus tôt si le serveur web demande autre chose (partie 5)."""
debut = time.monotonic()
while True:
for image, duree in etapes:
moment = time.monotonic()
matrice.montrer(image)
# On retire le temps déjà passé à afficher, pour garder le rythme.
matrice.attendre((duree - (time.monotonic() - moment)) * 1000)
if not attendre_ou_interrompre(duree - (time.monotonic() - moment)):
return
if time.monotonic() - debut >= duree_minimum:
break
def attendre_ou_interrompre(secondes):
"""Attend, par petits bouts de 0,2 s, en surveillant les demandes du
serveur web. Renvoie False si une demande est arrivée entre-temps."""
fin = time.monotonic() + secondes
while True:
if demande_en_attente():
return False
reste = fin - time.monotonic()
if reste <= 0:
return True
matrice.attendre(min(reste, 0.2) * 1000)
def jouer_animation(chemin, dossier):
"""Charge et joue UNE animation (fichier ou séquence), puis un court noir."""
nom = os.path.relpath(chemin, dossier)
print("▶", nom)
ecrire_en_cours(nom)
try:
etapes = charger(chemin)
except Exception as erreur:
# Un fichier abîmé ne doit pas arrêter la vitrine : on le saute.
print(f" ✗ impossible de lire ce fichier ({erreur}) : on passe au suivant")
return
if etapes:
jouer(etapes)
if PAUSE_ENTRE_ANIMATIONS > 0:
matrice.effacer()
matrice.montrer()
matrice.attendre(PAUSE_ENTRE_ANIMATIONS * 1000)
def ecran_d_attente(dossier):
"""Quand il n'y a rien à jouer : un petit point qui respire en bas à droite."""
print(f"Aucune animation dans {dossier} : en attente…")
@@ -261,11 +311,88 @@ def ecran_d_attente(dossier):
lumiere = int(60 + 60 * abs(25 - i) / 25)
matrice.pixel(LARGEUR - 1, HAUTEUR - 1, (0, lumiere, lumiere))
matrice.montrer()
matrice.attendre(100)
if not attendre_ou_interrompre(0.1):
return # une demande est arrivée depuis la page web
# =============================================================================
# 5. LES HORAIRES
# 5. PARLER AVEC LE SERVEUR WEB
# =============================================================================
#
# Le serveur web (serveur_web.py) et le lecteur sont deux programmes séparés,
# qui tournent en même temps. Pour se parler, ils s'échangent de tout petits
# fichiers, comme des post-it, dans un dossier qui est en MÉMOIRE VIVE
# (/run/vitrine-pixels) : rien n'est écrit sur la carte SD.
# - « en_cours » : le lecteur y écrit le nom de l'animation qu'il joue,
# pour que la page web puisse l'afficher ;
# - « demande » : le serveur y écrit le nom d'une animation à jouer
# TOUT DE SUITE, ou « suivante » pour passer à la suivante.
# Le lecteur la lit, puis efface le post-it.
SUIVANTE = "suivante"
def trouver_dossier_etat():
"""/run/vitrine-pixels sur le Pi ; le dossier temporaire sur un ordinateur
où l'on n'a pas le droit d'écrire dans /run."""
for dossier in ("/run/vitrine-pixels",
os.path.join(tempfile.gettempdir(), "vitrine-pixels")):
try:
os.makedirs(dossier, exist_ok=True)
if os.access(dossier, os.W_OK):
return dossier
except OSError:
pass
return tempfile.gettempdir()
DOSSIER_ETAT = trouver_dossier_etat()
FICHIER_EN_COURS = os.path.join(DOSSIER_ETAT, "en_cours")
FICHIER_DEMANDE = os.path.join(DOSSIER_ETAT, "demande")
def ecrire_post_it(fichier, texte):
try:
with open(fichier, "w") as f:
f.write(texte)
except OSError:
pass # pas grave : la page web n'affichera simplement pas l'info
def ecrire_en_cours(nom):
ecrire_post_it(FICHIER_EN_COURS, nom)
def demande_en_attente():
return os.path.exists(FICHIER_DEMANDE)
def lire_demande():
"""Renvoie la demande du serveur web (et l'efface), ou None s'il n'y en a pas."""
try:
with open(FICHIER_DEMANDE) as f:
demande = f.read().strip()
os.remove(FICHIER_DEMANDE)
return demande or None
except OSError:
return None
def traiter_demandes(dossier):
"""Joue les animations demandées depuis la page web, s'il y en a."""
demande = lire_demande()
while demande:
if demande != SUIVANTE:
chemin = os.path.join(dossier, demande)
if os.path.exists(chemin):
jouer_animation(chemin, dossier)
else:
print(" ✗ animation demandée introuvable :", demande)
demande = lire_demande()
# =============================================================================
# 6. LES HORAIRES
# =============================================================================
def en_horaires():
@@ -280,7 +407,7 @@ def en_horaires():
# =============================================================================
# 6. LE PROGRAMME PRINCIPAL
# 7. LE PROGRAMME PRINCIPAL
# =============================================================================
def main():
@@ -295,33 +422,33 @@ def main():
matrice.demarrer(simulateur=args.simulateur)
dossier = args.dossier or trouver_dossier()
# Quand le service est arrêté (systemctl stop, extinction du Pi), Linux
# envoie le signal « SIGTERM ». Par défaut, Python s'arrêterait net, sans
# passer par « finally » : la matrice resterait figée sur la dernière image.
# On transforme ce signal en sortie normale du programme.
signal.signal(signal.SIGTERM, lambda numero, cadre: sys.exit(0))
try:
while True: # boucle infinie : la vitrine tourne jusqu'à ce qu'on l'arrête
if not en_horaires():
ecrire_en_cours("(éteint : en dehors des horaires)")
matrice.eteindre()
matrice.attendre(30_000) # on revérifie l'heure toutes les 30 s
continue
# Un seul fichier demandé, ou tout le dossier ?
playlist = [args.chemin] if args.chemin else lister_animations(dossier)
playlist = [os.path.abspath(args.chemin)] if args.chemin \
else lister_animations(dossier)
if not playlist:
ecrire_en_cours("(rien à jouer)")
ecran_d_attente(dossier)
traiter_demandes(dossier)
continue
for chemin in playlist:
print("▶", os.path.relpath(chemin, dossier) if not args.chemin else chemin)
try:
etapes = charger(chemin)
except Exception as erreur:
# Un fichier abîmé ne doit pas arrêter la vitrine : on le saute.
print(f" ✗ impossible de lire ce fichier ({erreur}) : on passe au suivant")
continue
if etapes:
jouer(etapes)
if PAUSE_ENTRE_ANIMATIONS > 0:
matrice.effacer()
matrice.montrer()
matrice.attendre(PAUSE_ENTRE_ANIMATIONS * 1000)
traiter_demandes(dossier) # « jouer maintenant » depuis le web ?
if os.path.exists(chemin): # (elle a pu être supprimée entre-temps)
jouer_animation(chemin, dossier)
if not en_horaires():
break
@@ -333,6 +460,7 @@ def main():
# on ne laisse jamais la matrice allumée en partant.
try:
matrice.eteindre()
os.remove(FICHIER_EN_COURS)
except Exception:
pass