Guide de développement — ProJote
Ce document explique l'intégralité de l'architecture et du code du plugin ProJote. Il sert à la fois de documentation technique et de cours pratique pour comprendre comment développer un plugin Jeedom avec daemon Python.
1. Architecture générale d'un plugin Jeedom
Un plugin Jeedom suit toujours la même architecture en couches :
Navigateur (JS) ──AJAX──► ProJote.ajax.php ──► ProJote.class.php ──► BDD Jeedom
│
socket TCP
│
ProJoted.py (daemon)
│
API Pronote (internet)
│
HTTP callback (POST)
│
jeeProJote.php ──► ProJote.class.php
Le cœur du plugin est la classe PHP ProJote qui hérite de eqLogic
(un "équipement" Jeedom). Le daemon Python tourne en arrière-plan, collecte les données
Pronote, et les renvoie vers Jeedom via un callback HTTP.
2. Structure des fichiers du plugin
ProJote/
├── plugin_info/
│ ├── info.json ← Métadonnées obligatoires du plugin
│ ├── packages.json ← Dépendances système (apt, pip, npm)
│ └── ProJote.png ← Icône du plugin (128×128 px)
├── core/
│ ├── class/
│ │ └── ProJote.class.php ← Classe principale (eqLogic + cmd)
│ ├── ajax/
│ │ └── ProJote.ajax.php ← Endpoints AJAX (actions JS → PHP)
│ └── php/
│ └── jeeProJote.php ← Callback HTTP du daemon Python
├── resources/
│ └── ProJoted/
│ ├── ProJoted.py ← Daemon Python principal
│ ├── LoginConnect.py ← Gestion connexion login/password
│ ├── QRConnect.py ← Gestion connexion QR code
│ └── jeedom/
│ └── jeedom.py ← Module officiel Jeedom Python
├── desktop/
│ ├── php/
│ │ └── ProJote.php ← Vue liste des équipements
│ ├── js/
│ │ └── ProJote.js ← JavaScript frontend
│ └── modal/
│ └── cmd.config.*.html ← Templates modaux de config
├── core/template/
│ └── dashboard/
│ └── cmd.info.string.note.html ← Template widget
└── docs/
└── fr_FR/
├── index.html ← Documentation utilisateur
└── dev.html ← Ce fichier
3. plugin_info/ — Métadonnées du plugin
info.json
Ce fichier est obligatoire. Jeedom le lit pour connaître le plugin.
{
"id": "ProJote", // Identifiant unique — doit correspondre au nom du dossier
"name": "Plugin Pronote", // Nom affiché dans le market
"pluginVersion": "0.9d", // Version du plugin (semver recommandé)
"description": {
"fr_FR": "Plugin destinée à récupérer les informations de Pronote",
"en_US": "Plugin to retrieve information from Pronote school management platform"
},
"licence": "AGPL", // Obligatoire AGPL pour les plugins Jeedom
"author": "Aldarande",
"require": "4.4", // Version Jeedom minimale requise
"category": "organization",
"hasDependency": true, // Le plugin a des dépendances à installer
"hasOwnDeamon": true, // Le plugin gère son propre daemon
"maxDependancyInstallTime": 60,
"compatibility": ["smart", "luna", "atlas", "rpi", "docker", "diy", "mobile"]
}
id doit être identique au nom du dossier du plugin ET au nom de la classe PHP principale.
packages.json
Déclare les dépendances à installer automatiquement. Remplace les anciens scripts install_apt.sh.
{
"apt": {
"python3-dev": {},
"python3-venv": {},
"python3-requests": {},
"python3-pyudev": {}
},
"post-install": {
"script": "plugins/ProJote/resources/post-install.sh"
}
}
Le script post-install.sh crée le venv Python et installe les packages pip spécifiques (dont pronotepy).
4. core/class/ProJote.class.php — La classe principale
4.1 Héritage et structure
class ProJote extends eqLogic {
// Propriétés statiques spéciales lues par le Core Jeedom
public static $_encryptConfigKey = array('Token_password');
public static $_widgetPossibility = array(...);
// Méthodes statiques du daemon (Jeedom les appelle automatiquement)
public static function deamon_info() { ... }
public static function deamon_start() { ... }
public static function deamon_stop() { ... }
public static function dependancy_info() { ... }
public static function dependancy_install() { ... }
// Méthodes du cycle de vie d'un équipement
public function preSave() { ... }
public function postSave() { ... }
public function preRemove() { ... }
// Méthodes de cron
public static function cronHourly() { ... }
// Méthodes métier spécifiques à ProJote
public static function sendToDeamon($data) { ... }
public function getListeDefaultCommandes() { ... }
}
class ProJoteCmd extends cmd {
public function execute($_options = []) { ... }
}
4.2 $_encryptConfigKey — Chiffrement automatique
public static $_encryptConfigKey = array('Token_password');
Jeedom chiffre automatiquement en BDD les valeurs de configuration dont la clé est listée ici. Le déchiffrement est aussi automatique lors de la lecture. Utilisez-le pour les mots de passe, tokens, clés API.
4.3 $_widgetPossibility — Paramètres de widget
public static $_widgetPossibility = array(
'custom' => true,
'custom::layout' => false,
'parameters' => array(
'accent_color' => array( // Couleur d'accentuation (sélecteur)
'label' => 'Couleur d\'accentuation',
'type' => 'color',
'default' => '#94C904',
),
'font_size' => array( // Taille de police de base (select)
'label' => 'Taille de police',
'type' => 'select',
'default' => '12px',
'values' => array('10px'=>'10', '12px'=>'12 (défaut)', '14px'=>'14', '16px'=>'16'),
),
'default_tab' => array( // Onglet ouvert à l'affichage initial
'label' => 'Onglet par défaut',
'type' => 'select',
'default' => 'dv',
'values' => array('dv'=>'Devoirs', 'notes'=>'Notes', 'abs'=>'Absences',
'ret'=>'Retards', 'pun'=>'Punitions'),
),
'edt_nav_mode' => array( // Mode navigation EDT jours suivants
'label' => 'Navigation EDT jours suivants',
'type' => 'select',
'default' => 'next_day',
'values' => array('next_day'=>'Jour suivant (J+1)', 'arrows'=>'Flèches (J+1 à J+4)'),
),
),
);
Expose des paramètres configurables depuis l'onglet Affichage de l'équipement
(section Paramètres avancés). Ces valeurs sont accessibles dans toHtml()
via $replace['#param_name#'] et injectées en tant que placeholders dans le
template HTML (ex. #accent_color#, #font_size#, #default_tab#,
#edt_nav_mode#).
4.4 deamon_info() — État du daemon
public static function deamon_info() {
$return = array(
'log' => __CLASS__,
'state' => 'nok',
'launchable' => 'ok',
);
// Vérifier si le PID est actif
$pid_file = jeedom::getTmpFolder(__CLASS__) . '/deamon.pid';
if (file_exists($pid_file)) {
$pid = intval(file_get_contents($pid_file));
if (posix_getsid($pid) !== false) { // le processus est vivant
$return['state'] = 'ok';
}
}
// Vérifier si le daemon est lançable (configuration minimale)
$url = config::byKey('Url', __CLASS__);
if (empty($url)) {
$return['launchable'] = 'nok';
$return['launchable_message'] = 'URL Pronote non configurée';
}
return $return;
}
state:'ok'si le daemon tourne,'nok'sinonlaunchable:'ok'si la config est valide pour démarrerlaunchable_message: message affiché à l'utilisateur si'nok'
4.5 deamon_start() — Démarrage du daemon
public static function deamon_start() {
self::deamon_stop(); // S'assurer que l'instance précédente est arrêtée
$path = realpath(dirname(__FILE__) . '/../../resources/ProJoted');
$socketport = config::byKey('socketport', __CLASS__, '55369');
$callback = network::getNetworkAccess('internal', 'http:127.0.0.1:port:comp');
$loglevel = log::convertLogLevel(log::getLogLevel(__CLASS__));
$apikey = jeedom::getApiKey(__CLASS__);
$pid_file = jeedom::getTmpFolder(__CLASS__) . '/deamon.pid';
$data_dir = dirname(dirname(__FILE__)) . '/data';
// system::getCmdPython3() gère automatiquement venv Debian 12 vs système
$cmd = system::getCmdPython3(__CLASS__) . " {$path}/ProJoted.py";
$cmd .= ' --loglevel ' . $loglevel;
$cmd .= ' --socketport ' . $socketport;
$cmd .= ' --callback ' . $callback . '/plugins/ProJote/core/php/jeeProJote.php';
$cmd .= ' --apikey ' . $apikey;
$cmd .= ' --cycle 3';
$cmd .= ' --pid ' . $pid_file;
$cmd .= ' --datadir ' . escapeshellarg($data_dir);
exec($cmd . ' >> ' . log::getPathToLog(__CLASS__) . ' 2>&1 &');
// Attendre le démarrage (max 20s)
$i = 0;
while ($i < 20) {
sleep(1);
$deamon_info = self::deamon_info();
if ($deamon_info['state'] === 'ok') break;
$i++;
}
}
system::getCmdPython3(__CLASS__) et non un chemin Python hardcodé.
Cette méthode gère automatiquement le venv Python (Debian 12) ou le Python système selon la plateforme.
4.6 deamon_stop() — Arrêt du daemon
public static function deamon_stop() {
$pid_file = jeedom::getTmpFolder(__CLASS__) . '/deamon.pid';
if (file_exists($pid_file)) {
$pid = intval(file_get_contents($pid_file));
system::kill($pid); // Kill propre par PID
unlink($pid_file); // Supprimer le fichier PID
}
system::kill('ProJoted.py'); // Kill par nom de processus (sécurité)
}
shell_exec(system::getCmdSudo() . 'rm -rf ' . $pid_file) — inutilement root, et dangereux.
Utilisez simplement unlink().
4.7 preSave() — Avant sauvegarde d'un équipement
public function preSave() {
// Générer un UUID si absent (identifiant unique de l'équipement)
if (empty($this->getConfiguration('CmdId'))) {
$this->setConfiguration('CmdId', jeedom::createUniqueId());
}
// Largeur par défaut sur le dashboard (360px depuis v0.9b)
if (empty($this->getDisplay('width'))) {
$this->setDisplay('width', '360px');
}
// IMPORTANT : protéger les tokens de ne pas être écrasés lors d'une sauvegarde
// (la sauvegarde UI ne renvoie pas les champs chiffrés)
$existingConfig = eqLogic::byId($this->getId());
if ($existingConfig) {
$existing_token = $existingConfig->getConfiguration('Token_password');
$new_token = $this->getConfiguration('Token_password');
if (empty($new_token) && !empty($existing_token)) {
$this->setConfiguration('Token_password', $existing_token);
}
}
}
4.8 postSave() — Après sauvegarde
public function postSave() {
// Créer/mettre à jour les commandes par défaut
foreach ($this->getListeDefaultCommandes() as $cmdData) {
$cmd = $this->getCmd(null, $cmdData['logicalId']);
if (!is_object($cmd)) {
$cmd = new ProJoteCmd();
$cmd->setEqLogic_id($this->getId());
$cmd->setLogicalId($cmdData['logicalId']);
}
$cmd->setName($cmdData['name']);
$cmd->setType($cmdData['type']);
$cmd->setSubType($cmdData['subType'] ?? 'string');
$cmd->save();
}
}
4.9 sendToDeamon() — Envoyer une commande au daemon
public static function sendToDeamon($data) {
$socketport = config::byKey('socketport', __CLASS__, '55369');
$socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
if (@socket_connect($socket, '127.0.0.1', intval($socketport))) {
socket_write($socket, json_encode($data) . "\n");
socket_close($socket);
}
}
Cette méthode envoie un JSON au daemon Python via socket TCP. C'est le canal PHP → Python.
5. core/ajax/ProJote.ajax.php — Le handler AJAX
<?php
try {
require_once dirname(__FILE__) . '/../../../../core/php/core.inc.php';
include_file('core', 'authentification', 'php');
if (!isConnect('admin')) {
throw new Exception('{{401 - Accès non autorisé}}');
}
// Whitelist obligatoire depuis Jeedom Core 4.1
ajax::init(['Validate', 'ValidateQRCode', 'ChangeEnfant', 'GetConfig', 'GetWidgetData']);
$action = init('action');
if ($action == 'Validate') {
$eqLogicId = init('eqLogicId');
$url = init('url');
// ... traitement ...
ajax::success();
}
if ($action == 'GetWidgetData') {
$eqLogicId = init('eqLogicId');
$eqLogic = ProJote::byId($eqLogicId);
$data = $eqLogic->getConfiguration('widget_json', '{}');
ajax::success(json_decode($data, true));
}
throw new Exception('Action non reconnue : ' . $action);
} catch (Exception $e) {
ajax::error(displayException($e), $e->getCode());
}
ajax::init(['action1', 'action2']) avec la liste blanche des actions est obligatoire
depuis Core 4.1. Sans cela, toutes les actions passent — faille de sécurité.
Côté JavaScript, les appels se font via :
// Jeedom Core 4.4 — utiliser domUtils.ajax (Vanilla JS)
domUtils.ajax({
type: 'POST',
url: 'plugins/ProJote/core/ajax/ProJote.ajax.php',
data: { action: 'GetWidgetData', eqLogicId: eqLogic.id },
success: function(data) {
// data contient le résultat de ajax::success($data)
},
error: function(error) { console.error(error); }
});
6. core/php/jeeProJote.php — Le callback daemon
Ce fichier est l'endpoint HTTP que le daemon Python appelle pour envoyer ses données à Jeedom.
<?php
require_once dirname(__FILE__) . '/../../../../core/php/core.inc.php';
// Vérifier l'API key — OBLIGATOIRE pour les callbacks daemon
jeedom::apiAccess(init('apikey'), 'ProJote');
$result = json_decode(file_get_contents('php://input'), true);
if (!is_array($result)) {
die();
}
$eqLogicId = $result['CmdId'] ?? null;
$eqLogic = ProJote::byId($eqLogicId);
if (!is_object($eqLogic)) {
die();
}
// Mettre à jour les commandes Jeedom avec les nouvelles valeurs
$eqLogic->checkAndUpdateCmd('connection_status', $result['connection_status'] ?? '');
$eqLogic->checkAndUpdateCmd('nb_devoirs', count($result['Devoirs'] ?? []));
// ... autres commandes ...
// Sauvegarder les données JSON complètes pour le widget
$eqLogic->setConfiguration('widget_json', json_encode($result));
$eqLogic->save(true); // true = sauvegarde directe sans cycle complet
// Persister sur disque pour la reconnexion par token
saveDataToJsonFile($eqLogic, $result);
checkAndUpdateCmd)
et sauvegarde le JSON complet dans la config de l'équipement pour le widget.
checkAndUpdateCmd() — Mise à jour des valeurs de commandes
// Met à jour la valeur d'une commande ET déclenche les scénarios associés
$eqLogic->checkAndUpdateCmd('logicalId', $newValue);
Cette méthode est fondamentale : elle met à jour la valeur de la commande en BDD, met à jour le cache, et déclenche les scénarios Jeedom qui écoutent cette commande.
7. resources/ProJoted/ProJoted.py — Le daemon Python
7.1 Structure générale
# Arguments reçus au démarrage depuis PHP
parser = argparse.ArgumentParser()
parser.add_argument("--loglevel")
parser.add_argument("--callback") # URL HTTP de retour vers Jeedom
parser.add_argument("--apikey") # Clé API Jeedom pour le callback
parser.add_argument("--socketport") # Port TCP d'écoute (commandes PHP → Python)
parser.add_argument("--pid") # Chemin du fichier PID
parser.add_argument("--cycle") # Fréquence de rafraîchissement
parser.add_argument("--datadir") # Chemin du dossier data du plugin
args = parser.parse_args()
_data_dir = args.datadir or "/var/www/html/plugins/ProJote/data"
7.2 Module jeedom.py — Communication bidirectionnelle
from jeedom.jeedom import jeedom_utils, jeedom_com, jeedom_socket
# Initialiser le socket d'écoute (PHP → Python)
jeedom_socket = jeedom_socket(port=_socket_port, address='127.0.0.1')
jeedom_socket.open()
# Envoyer des données vers PHP (Python → PHP via HTTP)
jeedom_com = jeedom_com(apikey=_apikey, loglevel=_log_level, url=_callback)
jeedom_com.send_change_immediate({'CmdId': eqid, 'Notes': [...], ...})
7.3 Boucle principale — Architecture multi-thread
Depuis la version 0.9, chaque équipement est traité dans un thread Python dédié.
La fonction read_socket() dépile les messages et délègue immédiatement
à un thread par eqLogicId — le daemon ne se bloque plus sur un équipement lent.
import threading
# ── Verrous et registre des threads actifs ───────────────
_failed_attempts_lock = threading.Lock()
_active_eq_lock = threading.Lock()
_active_eq = {} # {eqLogicId: threading.Thread}
def process_message(message):
"""Traitement complet d'un équipement — exécuté dans son propre thread."""
eq_id = str(message.get("CmdId", ""))
try:
# ... connexion Pronote + collecte des données ...
with _failed_attempts_lock:
failed_attempts[eq_id] = {"count": 0, "timestamp": time.time()}
except Exception as e:
logging.error("Erreur traitement équipement %s : %s", eq_id, e)
finally:
# Libère toujours le slot, même en cas d'exception
with _active_eq_lock:
_active_eq.pop(eq_id, None)
def read_socket():
"""Dépile les messages entrants et spawne un thread par équipement."""
global JEEDOM_SOCKET_MESSAGE
try:
if JEEDOM_SOCKET_MESSAGE.empty():
return
message = json.loads(JEEDOM_SOCKET_MESSAGE.get().decode("utf-8"))
eq_id = str(message.get("CmdId", ""))
with _active_eq_lock:
existing = _active_eq.get(eq_id)
if existing and existing.is_alive():
# Requête ignorée : l'équipement est déjà en cours de traitement
logging.warning("Équipement %s déjà en cours — requête ignorée.", eq_id)
return
t = threading.Thread(
target=process_message, args=(message,),
daemon=True, name=f"eq-{eq_id}"
)
_active_eq[eq_id] = t
t.start()
except Exception as e:
logging.error("Erreur dans read_socket : %s", e)
failed_attempts sont protégés par
_failed_attempts_lock. Le registre _active_eq est protégé par
_active_eq_lock. Le finally dans process_message
garantit la libération du slot même en cas d'exception.
7.4 Monkey patches pronotepy
ProJote applique deux corrections à la volée sur la librairie pronotepy :
# Patch 1 : Grade.__init__ — gère les champs optionnels absents
# Sans ce patch, une note sans noteMax/noteMin lève une KeyError
_original_grade_init = pronotepy.dataClasses.Grade.__init__
def _patched_grade_init(self, json_data):
for field in ["noteMax", "noteMin", "coefficient", "commentaire",
"estBonus", "estFacultatif", "estRamenerSur20"]:
if field not in json_data:
json_data[field] = {"V": "" if field != "estBonus" else False}
_original_grade_init(self, json_data)
pronotepy.dataClasses.Grade.__init__ = _patched_grade_init
# Patch 2 : html_parse — préserve les <br> comme espaces
# Sans ce patch : "Faire ex 1<br>Apprendre" → "Faire ex 1Apprendre"
@staticmethod
def _patched_html_parse(html_text):
text = re.sub(r'<br\s*/?>', ' ', html_text, flags=re.IGNORECASE)
text = re.sub(r'<[^>]+>', '', text)
return html.unescape(text).strip()
pronotepy.dataClasses.Util.html_parse = _patched_html_parse
7.5 Connexion à Pronote
# Méthode 1 : Login / mot de passe
client = pronotepy.ParentClient(url, username=login, password=password, ent=ent_func)
# Méthode 2 : QR Code (token)
qr_data = {'jeton': token, 'login': login}
client = pronotepy.ParentClient.token_login(url, username=login,
token=token, uuid=uuid, ent=ent_func)
# Méthode 3 : Reconnexion token persistant (depuis fichier JSON)
token_data = load_persistent_token(eqLogicId)
if token_data:
client = pronotepy.ParentClient.token_login(...)
# Sélectionner l'enfant (client ParentClient)
client.set_child(child_name)
7.6 Collecte des données Pronote
| Fonction Python | Données collectées | Commandes Jeedom |
|---|---|---|
Emploidutemps() | EDT aujourd'hui + 4 prochains jours scolaires (batch sur 14 j), compteur cours annulés depuis la rentrée | edt_aujourdhui, edt_J1…edt_J4, edt_prochainjour (= J1, rétro-compat) |
Notes() | Notes de toutes les périodes + moyennes | nb_notes, moy_generale, etc. |
Devoirs() | Devoirs du jour et du lendemain + fichiers joints | nb_devoirs, devoirs_aujourdhui |
Absences() | Liste des absences | nb_absences |
Retards() | Liste des retards | nb_retards |
Punitions() | Liste des punitions/retenues | nb_punitions |
Notifications() | Notifications/informations Pronote | nb_notifications |
Competences() | Évaluations par compétences | nb_competences |
Menus() | Menus de la cantine | menus |
download_photo() | Photo de profil de l'élève | photo_eleve |
7.7 Notes() — Structure et calcul de la moyenne
La fonction Notes() itère sur toutes les périodes de client.periods et collecte :
- Chaque note individuelle (
grade.grade,grade.out_of,grade.coefficient,grade.average,grade.is_optionnal,grade.is_bonus) - La moyenne officielle de la période (
period.overall_average) et la moyenne de classe (period.class_overall_average)
La structure renvoyée au widget via widget_json.moyennes_periodes est :
moyennes_periodes = [
{
"periode": "Semestre 1",
"moyenne_eleve": "16.5", # str, virgule → point ; "" si non publiée
"moyenne_classe": "14.2",
},
...
]
Seules les périodes ayant au moins une valeur non vide (overall_average ou class_overall_average) sont incluses — Pronote ne publie la moyenne officielle qu'après clôture du bilan de période.
Affichage côté widget (JS)
Le widget JS utilise en priorité moyennes_periodes[dernière entrée].moyenne_eleve. Si cette valeur est vide (période en cours, bilan non clôturé), il bascule sur un calcul local pondéré :
// Calcul local (fallback)
tot = 0; coefs = 0;
notes.forEach(n => {
r = parseFloat(n.note) / parseFloat(n.sur || '20'); // ratio /1
c = parseFloat(n.coeff ?? '1');
if (!isNaN(r) && !isNaN(c)) { tot += r * 20 * c; coefs += c; }
});
moyenne = coefs > 0 ? (tot / coefs).toFixed(1) : '–';
Cas particuliers gérés :
| Cas | Comportement |
|---|---|
note = "Abs", "Disp"… | parseFloat() retourne NaN → note exclue du calcul |
coeff = 0 | Contribue 0 au numérateur et 0 au dénominateur → sans effet sur la moyenne |
coeff décimal (1.5, 0.5…) | Géré nativement par parseFloat() |
sur absent | Défaut 20 |
7.8 Emploidutemps() — Collecte des 4 prochains jours (v0.9b)
Avant la v0.9b, le daemon effectuait N appels API séquentiels pour trouver le prochain jour
scolaire (boucle delta++). Désormais un seul appel sur 14 jours
est effectué, les cours sont groupés par date et les 4 premières dates non vides sont conservées.
# 1 appel API sur 14 jours au lieu de N appels
lessons_range = client.lessons(today + timedelta(days=1), today + timedelta(days=14))
# Grouper par date (lesson.start est un datetime)
days_by_date = {}
for lesson in lessons_range:
d = lesson.start.date()
days_by_date.setdefault(d, []).append(lesson)
# Prendre les 4 premières dates avec cours
next_school_dates = sorted(days_by_date.keys())[:4]
# Stocker dans data["edt_J1"] … data["edt_J4"]
# + data["edt_J1_date"], data["edt_J1_debut"], data["edt_J1_fin"], data["edt_J1_cancel"]
# Rétro-compatibilité obligatoire :
data["edt_prochainjour"] = data["edt_J1"] # = J1
data["edt_prochainjour_date"] = data["edt_J1_date"]
# Tableau compact pour le widget (évite la duplication dans widget_json)
data["edt_next_days"] = [
{"cours": data["edt_J1"], "date": "...", "debut": "...", "fin": "...", "cancel": 0},
# … J2, J3, J4 si disponibles
]
Structure des commandes Jeedom générées :
| LogicalId | Description | Type |
|---|---|---|
edt_J{n} | Liste des cours du jour J+n (JSON) | info/string |
edt_J{n}_date | Date du jour J+n (jj/mm/aaaa) | info/string |
edt_J{n}_debut | Heure de début (HHMM) | info/string |
edt_J{n}_fin | Heure de fin (HHMM) | info/string |
edt_J{n}_cancel | Nombre de cours annulés | info/numeric |
n ∈ {1, 2, 3, 4} — soit 20 nouvelles commandes. edt_prochainjour reste = J1.
jeeProJote.php stocke edt_next_days (tableau compact)
dans widget_json. Le widget JS lit d.edt_next_days[edtDayIdx]
pour afficher le jour courant dans la colonne droite.
7.8 Circuit breaker — Protection anti-boucle
failed_attempts = {} # Compteur d'échecs par eqLogicId
def check_circuit_breaker(eqLogicId, max_attempts=5):
"""Empêche les reconnexions infinies en cas d'échec répété."""
if eqLogicId not in failed_attempts:
failed_attempts[eqLogicId] = {"count": 0, "last_attempt": 0}
now = time.time()
# Réinitialiser après 5 minutes
if now - failed_attempts[eqLogicId]["last_attempt"] > 300:
failed_attempts[eqLogicId]["count"] = 0
if failed_attempts[eqLogicId]["count"] > max_attempts:
logging.error(f"Circuit breaker bloqué pour {eqLogicId}")
return False
failed_attempts[eqLogicId]["count"] += 1
failed_attempts[eqLogicId]["last_attempt"] = now
return True
7.8 Renouvellement automatique des tokens backup
Quand le token principal est expiré, le daemon utilise le token de secours (backup token) pour reconnecter l'équipement. Il profite ensuite de cette session active pour renouveler les deux tokens et les sauvegarder — le cycle de vie est ainsi remis à zéro sans intervention de l'utilisateur.
# Après connexion réussie via le token backup :
logging.warning(
"ProJote — Token principal expiré pour l'équipement %s. "
"Token backup utilisé. Renouvellement automatique des tokens en cours.",
eqLogicId,
)
new_backup_credentials = None
try:
# Créer une seconde session avec un UUID modifié pour obtenir de nouveaux credentials
renew_uuid = backup_uuid + "-bk" if not backup_uuid.endswith("-bk") else backup_uuid + "2"
new_backup_client = pronotepy.ParentClient.token_login(
pronote_url=backup_token["pronote_url"],
username=backup_token["username"],
password=backup_token["password"], # = token dans les credentials pronotepy
client_identifier=backup_token["client_identifier"],
uuid=renew_uuid,
)
if new_backup_client and new_backup_client.logged_in:
new_backup_credentials = new_backup_client.export_credentials()
logging.info("Token backup renouvelé avec succès pour l'équipement %s.", eqLogicId)
else:
logging.warning("ProJote — Token backup non renouvelé pour l'équipement %s.", eqLogicId)
except Exception as e_bk:
logging.warning("ProJote — Renouvellement backup échoué pour %s : %s", eqLogicId, e_bk)
# Sauvegarder les deux tokens (principal via `client`, backup via `new_backup_credentials`)
writedataPronotepy(client, _data_dir, eqLogicId, backup_token=new_backup_credentials)
logging.warning(
"ProJote — Tokens renouvelés pour l'équipement %s. "
"Token backup : %s.",
eqLogicId,
"OK" if new_backup_credentials else "NON RENOUVELÉ",
)
WARNING dans les logs ProJote :
détection du token expiré, tentative de renouvellement, résultat (succès ou échec).
Ces messages sont visibles sans activer le mode debug.
8. Widget et templates HTML
8.1 Template widget (ProJote.html)
Le fichier core/template/dashboard/ProJote.html
définit le rendu visuel complet du tableau de bord. Toutes les règles CSS sont préfixées
par .pjw-#id# pour éviter tout conflit avec d'autres widgets Jeedom.
Le JavaScript est encapsulé dans une IIFE ((function(){})();).
Variables JS injectées par toHtml() :
| Variable JS | Placeholder PHP | Défaut | Rôle |
|---|---|---|---|
D | #initData# | {} | Données widget (JSON complet de widget_json) |
VIS | #visibilityMap# | tout visible | Carte de visibilité des sections selon les commandes masquées |
DEFAULT_TAB | #default_tab# | 'dv' | Identifiant de l'onglet actif au chargement |
EDT_NAV_MODE | #edt_nav_mode# | 'next_day' | 'next_day' = J+1 fixe, 'arrows' = navigation J+1→J+4 |
LAST_LOGIN_CMD_ID | #lastLoginCmdId# | 0 | ID de la commande LastLogin pour déclencher le refresh |
Flux de données :
- Au chargement,
D(#initData#) est injecté directement —render()est appelé immédiatement si non vide. - Quand le daemon met à jour la commande
LastLogin,jeedom.cmd.addUpdateFunction()déclenche un appel AJAXGetWidgetDataqui rappellerender(). - L'objet JSON est distribué aux renderers :
renderHeader,renderEDT,renderNotesBar,renderDevoirs,renderDernieresNotes,renderAbsences,renderRetards,renderPunitions.
<script>
(function () {
'use strict';
var ID = '#id#'; // Remplacé par Jeedom avec l'ID réel
// Liaison Jeedom : appelé à chaque changement de valeur
jeedom.cmd.addUpdateFunction('#id#', function(_opts) {
var v = _opts.value;
if (!v || v.trim() === '' || v === '{}') return;
try {
render(JSON.parse(v), _opts.collectDate || _opts.valueDate);
} catch(e) {
console.error('[ProJote] Erreur JSON :', e);
}
});
// Premier rendu avec la valeur déjà en cache
showSpinner(); // Spinner actif pendant le chargement initial
jeedom.cmd.refreshValue([{
cmd_id: '#id#',
value: '#value#',
collectDate: '#collectDate#',
}]);
})();
</script>
8.2 Spinner de chargement
Pendant la collecte des données (chargement initial ou rafraîchissement manuel), le logo ProJote tourne dans la zone avatar grâce à une animation CSS scoped.
<!-- CSS : animation scoped par instance de widget -->
@keyframes pjwSpin#id# {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.pjw-#id# .ph-av { cursor: pointer; }
.pjw-#id# .ph-av-loading img {
animation: pjwSpin#id# 1.1s linear infinite;
border-radius: 12px;
opacity: .85;
}
// JavaScript
var _spinning = false;
function showSpinner() {
_spinning = true;
var av = el('pjwav' + ID);
if (!av) return;
av.classList.add('ph-av-loading');
av.innerHTML = '<img src="/plugins/ProJote/plugin_info/ProJote_icon.png" alt="">';
}
function hideSpinner() {
_spinning = false;
var av = el('pjwav' + ID);
if (!av) return;
av.classList.remove('ph-av-loading');
}
// Clic sur l'avatar → rafraîchissement manuel
av.addEventListener('click', function() {
if (_spinning) return; // Ignore si déjà en cours
showSpinner();
jeedom.cmd.execute({id: ID});
});
// hideSpinner() est appelé au début de render() pour stopper l'animation
function render(d, ts) {
hideSpinner();
// ... rendu des données ...
}
#id# à l'affichage. Chaque instance du widget obtient
donc un nom d'animation unique (pjwSpin42, pjwSpin43…)
— plusieurs widgets ProJote sur le même dashboard ne se perturbent pas.
8.3 Navigation EDT — flèches J+1 à J+4
Quand le paramètre edt_nav_mode vaut 'arrows', la colonne droite
de l'EDT affiche des boutons ‹ › permettant de naviguer entre J+1 et J+4.
Les données viennent de d.edt_next_days (tableau de 1 à 4 objets).
// Variables module-level (IIFE)
var edtDayIdx = 0; // index courant dans edt_next_days (0 = J+1)
var edtCurrentData = null; // données en cours (mises à jour à chaque render)
function renderEdtDay(d) {
var days = (d && d.edt_next_days) || [];
var day = days[edtDayIdx] || null;
var showNav = EDT_NAV_MODE === 'arrows';
// Afficher/masquer les flèches
prev.style.display = showNav ? '' : 'none';
next.style.display = showNav ? '' : 'none';
// Rendre le jour sélectionné
lbl.textContent = day ? day.date : 'Prochain jour';
ec.innerHTML = day ? edtCoursesHtml(mergeAndSort(day.cours, retenues)) : '...';
// Griser les flèches aux extrémités
if (showNav) {
prev.disabled = (edtDayIdx === 0);
next.disabled = (edtDayIdx >= days.length - 1);
}
}
function initEDT() {
prev.addEventListener('click', function() {
if (edtDayIdx > 0) { edtDayIdx--; renderEdtDay(edtCurrentData); }
});
next.addEventListener('click', function() {
var max = (edtCurrentData.edt_next_days || []).length - 1;
if (edtDayIdx < max) { edtDayIdx++; renderEdtDay(edtCurrentData); }
});
}
‹ / › sont dans le DOM avec
id="pjwnav-prev#id#" et id="pjwnav-next#id#".
En mode next_day, ils sont cachés (display:none) mais restent
dans le DOM — aucun changement de structure HTML entre les deux modes.
8.4 Onglet par défaut
Le HTML hardcode class="tab-btn active" et class="tab-pane active"
sur l'onglet Devoirs (dv). Si DEFAULT_TAB !== 'dv',
initTabs() retire les classes active de tous les éléments
et les réapplique sur l'onglet configuré.
function initTabs() {
if (DEFAULT_TAB && DEFAULT_TAB !== 'dv') {
// Retirer l'état actif du défaut HTML
tabsEl.querySelectorAll('.tab-btn, .tab-pane').forEach(function(el) {
el.classList.remove('active');
});
// Activer l'onglet configuré
var btn = tabsEl.querySelector('[data-tab="' + DEFAULT_TAB + ID + '"]');
var pane = el(DEFAULT_TAB + ID);
if (btn) btn.classList.add('active');
if (pane) pane.classList.add('active');
}
// Gestion des clics...
}
9. desktop/ — Interface utilisateur
9.0 Onglet Affichage — paramètres widget & photo
L'onglet Affichage de l'équipement présente une mise en page deux colonnes : à gauche les paramètres du widget, à droite une prévisualisation live.
Paramètres widget
Les valeurs sont stockées sous display.parameters_xxx (clé plate, préfixe parameters_) :
| Champ HTML | Clé display | Usage dans toHtml() |
|---|---|---|
data-l2key="parameters_accent_color" | display.parameters_accent_color | $this->getDisplay('parameters_accent_color') |
data-l2key="parameters_font_size" | display.parameters_font_size | $this->getDisplay('parameters_font_size') |
data-l2key="parameters_default_tab" | display.parameters_default_tab | $this->getDisplay('parameters_default_tab') |
data-l2key="parameters_edt_nav_mode" | display.parameters_edt_nav_mode | $this->getDisplay('parameters_edt_nav_mode') |
preToHtml() expose les paramètres via $replace['#key#']
uniquement si ils sont stockés sous display.parameters.key (sous-tableau).
Nos champs utilisent display.parameters_key (clé plate) — il faut donc lire via
$this->getDisplay('parameters_key'), pas via $replace['#parameters_key#'].
Photo de profil
Trois emplacements possibles :
data/{id}/profile_picture.jpg— photo téléchargée depuis Pronote (par le démon)data/{id}/profile_picture_manual.jpg— photo uploadée manuellement par l'utilisateur- Initiales générées en CSS (aucun fichier)
Le champ configuration.photo_source détermine laquelle utiliser (none, pronote,
manual, auto). La résolution est effectuée dans deux endroits :
jeeProJote.php— lors du refresh Pronote, met à jour la commandePictureet stockepronote_photo+photodanswidget_json.toHtml()— recalcule en temps réel à chaque rendu (indépendant du démon). Permet à un changement dephoto_sourceou un upload manuel d'être visible immédiatement sans refresh Pronote.
Upload / suppression (AJAX)
Deux actions dans ProJote.ajax.php :
| Action | Description |
|---|---|
UploadManualPhoto | Valide le MIME (JPEG/PNG/WebP), max 5 Mo, déplace vers data/{id}/profile_picture_manual.jpg |
DeleteManualPhoto | Supprime data/{id}/profile_picture_manual.jpg (succès même si absent) |
9.1 desktop/php/ProJote.php — Vue liste
Rendu côté serveur de la page d'administration du plugin. Jeedom l'inclut dans l'interface.
<?php
if (!isConnect('admin')) {
throw new Exception('{{401 - Accès non autorisé}}');
}
$plugin = plugin::byId('ProJote');
if ($plugin === null) {
throw new Exception('{{Plugin ProJote non trouvé}}');
}
$eqLogics = eqLogic::byType($plugin->getId());
sendVarToJS('eqType', $plugin->getId());
sendVarToJS('LogLevel', log::convertLogLevel(log::getLogLevel($plugin->getId())));
?>
<div class="row">
<!-- Liste des équipements -->
<div class="eqLogicThumbnailDisplay">
<?php foreach ($eqLogics as $eqLogic) { ... } ?>
</div>
</div>
ProJote.js
via la variable globale correspondante.
9.2 desktop/js/ProJote.js — JavaScript frontend
Jeedom 4.4 utilise Vanilla JS (pas jQuery). Les APIs principales :
// Initialisation au chargement de la page
document.addEventListener('DOMContentLoaded', function() {
jeedomUtils.initPage({
eqType: eqType,
onEqLogicSelect: function(eqLogic) {
loadEqLogicConfig(eqLogic);
}
});
});
// Appel AJAX
domUtils.ajax({
type: 'POST',
url: 'plugins/ProJote/core/ajax/ProJote.ajax.php',
data: { action: 'GetConfig', eqLogicId: id },
success: function(data) { ... },
error: function(error) { jeedomUtils.showAlert({message: error, level: 'danger'}); }
});
// Boîte de dialogue
jeeDialog.dialog({
title: 'Scan QR Code',
content: '<canvas id="qr-canvas"></canvas>',
buttons: {
Fermer: { click: function() { jeeDialog.closeAll(); } }
}
});
10. Communication PHP ↔ Python
10.1 PHP → Python (commandes en temps réel)
Jeedom envoie des commandes au daemon via socket TCP :
// PHP — envoyer une commande
$socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
socket_connect($socket, '127.0.0.1', 55369);
socket_write($socket, json_encode(['action' => 'refresh', 'eqLogicId' => 42]) . "\n");
socket_close($socket);
# Python — recevoir une commande
message = jeedom_socket.get()
data = json.loads(message)
if data['action'] == 'refresh':
collect_data(data['eqLogicId'])
10.2 Python → PHP (envoi de données)
Le daemon envoie ses données via HTTP POST vers le callback :
# Python — envoyer les données vers Jeedom
payload = {
'CmdId': eqLogicId,
'connection_status': 'connected',
'Notes': [...],
'Devoirs': [...],
# ... toutes les données ...
}
jeedom_com.send_change_immediate(payload)
# Sous le capot, jeedom_com fait :
requests.post(
callback_url,
params={'apikey': apikey},
json=payload
)
10.3 Schéma complet
Utilisateur clique "Rafraîchir"
│
▼
ProJote.js ──POST──► ProJote.ajax.php (action=Validate)
│
▼
ProJote.class.php::sendToDeamon()
│
Socket TCP
│
▼
ProJoted.py (reçoit la commande)
│
pronotepy (API Pronote)
│
Collecte toutes les données
│
HTTP POST
│
▼
jeeProJote.php
│
▼
checkAndUpdateCmd() → commandes Jeedom
setConfiguration('widget_json') → widget
saveDataToJsonFile() → persistance disque
11. Sécurité
11.1 Authentification dans les fichiers PHP
// Pour les pages admin (desktop/php/, ajax/)
if (!isConnect('admin')) {
throw new Exception('{{401 - Accès non autorisé}}');
}
// Pour les callbacks daemon (core/php/)
jeedom::apiAccess(init('apikey'), 'ProJote');
// Lance une exception si la clé est invalide → arrête le traitement
11.2 Whitelist AJAX
// Obligatoire en Core 4.1+ — liste explicite des actions autorisées
ajax::init(['Validate', 'ValidateQRCode', 'ChangeEnfant', 'GetConfig', 'GetWidgetData']);
11.3 Niveaux de log valides
// CORRECT
log::add(__CLASS__, 'debug', $message);
log::add(__CLASS__, 'info', $message);
log::add(__CLASS__, 'warning', $message);
log::add(__CLASS__, 'error', $message); // et non 'erreur' !
// INCORRECT — "erreur" n'existe pas dans Jeedom
log::add(__CLASS__, 'erreur', $message); // ← bug silencieux
11.4 Chiffrement des données sensibles
// Lister les clés à chiffrer automatiquement
public static $_encryptConfigKey = array('Token_password', 'password');
// En lecture, Jeedom déchiffre automatiquement
$token = $eqLogic->getConfiguration('Token_password'); // déjà déchiffré
11.5 Échappement shell
// Toujours échapper les valeurs passées en shell
$cmd .= ' --datadir ' . escapeshellarg($data_dir);
// Éviter les injections dans les requêtes DB
// → Utiliser les méthodes Jeedom (getConfiguration, setConfiguration)
// → Éviter les requêtes SQL directes
Logs et dépannage du daemon
Structure des logs en mode séquentiel
Depuis la version 0.9, le daemon utilise une file d'attente unique : un seul équipement est traité à la fois. Les logs sont donc parfaitement linéaires et faciles à lire.
# Exemple de séquence normale avec 2 équipements
[INFO] Équipement 42 ajouté à la file (taille file : 1).
[INFO] Équipement 43 ajouté à la file (taille file : 2).
[INFO] === Début traitement équipement 42 (file restante : 1) ===
[INFO] Reconnexion avec token persistant réussie !
[INFO] Je récupère l'emploi du temps
[INFO] Je récupère les notes
...
[INFO] === Fin traitement équipement 42 ===
[INFO] === Début traitement équipement 43 (file restante : 0) ===
...
[INFO] === Fin traitement équipement 43 ===
Identifier un équipement bloqué
Le watchdog émet un WARNING si un équipement monopolise le worker
plus de 120 secondes :
[WARNING] ProJote — Watchdog : équipement 42 en cours depuis 121s
(timeout 120s). Le worker est peut-être bloqué (Pronote injoignable ?).
Libération forcée du slot.
Causes possibles et solutions :
| Symptôme dans les logs | Cause probable | Action |
|---|---|---|
| Watchdog déclenché toutes les synchros | Timeout réseau sur le serveur Pronote de l'établissement | Vérifier la connectivité réseau depuis le serveur Jeedom vers Pronote |
=== Début eq-X === sans === Fin === correspondant |
Crash silencieux dans process_message |
Passer en mode Debug et relancer |
Équipement X déjà dans la file — requête ignorée |
Normal : double clic ou cron en avance | Aucune action requise |
Token principal expiré … Token backup utilisé |
Token principal Pronote périmé (normal après quelques semaines) | Les deux tokens sont renouvelés automatiquement — surveiller le WARNING suivant |
Token backup non renouvelé |
Les deux tokens sont expirés simultanément | Re-valider la connexion via QR Code ou identifiants |
Commandes utiles pour le dépannage
# Voir les logs en temps réel (remplacer par le bon chemin Jeedom)
tail -f /var/www/html/log/ProJote
# Filtrer les lignes d'un seul équipement (ex: id 42)
grep "équipement 42\|eq-42" /var/www/html/log/ProJote
# Voir uniquement les débuts/fins de traitement
grep "=== " /var/www/html/log/ProJote
# Voir les avertissements et erreurs uniquement
grep -E "\[WARNING\]|\[ERROR\]" /var/www/html/log/ProJote
# Vérifier que le daemon tourne
ps aux | grep ProJoted
12. Checklist de mise en production
| Élément | Vérification | Statut ProJote 0.9 |
|---|---|---|
info.json |
licence=AGPL, require="4.4", description bilingue | ✅ Corrigé |
packages.json |
Pas de sections vides (pip3, npm, yarn...) | ✅ Corrigé |
ajax::init() |
Whitelist explicite des actions | ✅ Corrigé |
log::add() |
Niveau valide (debug/info/warning/error) | ✅ Corrigé |
system::getCmdPython3() |
Utilisé pour lancer le daemon (Debian 12) | ✅ Corrigé |
| PID cleanup | unlink() et non shell_exec(rm) |
✅ Corrigé |
| Chemins hardcodés Python | Utilise _data_dir passé via --datadir |
✅ Corrigé |
| Code de test Python | Aucune date hardcodée, aucun code de debug | ✅ Corrigé |
message::add() |
Signature : ($_type, $_message, $_action, $_logicalId) |
✅ Corrigé |
error_log() |
Aucun appel debug en production | ✅ Corrigé |
| Tokens protégés | Non écrasés lors des sauvegardes UI | ✅ OK |
| Monkey patches | Grade.__init__ et html_parse patchés | ✅ OK |
| Circuit breaker | Protection anti-boucle de reconnexion | ✅ OK |