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:
+150
-22
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user